Appearance
Data Layer
The data layer is the part of the application that exposes data to the rest of the system while hiding where the data comes from and how it is accessed.
It exists so the domain layer and other application layers do not need to know:
- whether data comes from an API, OIDC provider, browser storage, or memory
- how requests are built
- how responses are parsed
- how persistence details are handled
- how multiple sources are coordinated
In this repository, the data layer is organized per feature under src/core/data/feature-name and usually contains:
repository/remote/local/when local persistence is neededmodel/
This document explains the data-layer structure used in this repository using general repository-pattern terminology.
Architecture In This Repository
The current codebase uses these abstraction-and-implementation patterns:
RepositoryandRepositoryImplRemoteandRemoteImplLocalandLocalImpl
Some features use only a remote source, some use remote plus local, and auth uses an OIDC-specific source (AuthOidc and AuthOidcImpl) that plays the same role as a data source.
That means the conceptual structure is simpler if we read each pattern as one responsibility with two parts:
- an abstraction that defines what can be done
- an implementation that defines how it is done
In some architectures, the implementation class is often called DataStore. In this repository, that same role is usually handled by RepositoryImpl.
Why It Matters
Repository and data-layer structure matter because they give the application one consistent place to access and manipulate data.
This matters because it helps us:
- isolate the application from concrete data sources
- keep data access in one place instead of spreading it into domain or presentation
- combine multiple sources without exposing that complexity upward
- replace remote or local implementations with less impact to the rest of the app
- keep data manipulation, caching, persistence, and mapping inside the data layer
The main principle is simple: data manipulation stays inside the data layer, and other layers access data through the repository abstraction.
Feature Structure
This repository follows a feature-first data structure, which keeps related repository, source, and model code close to each other.
A feature usually looks like this:
text
repository/
remote/
local/
model/The responsibility of each folder is:
repository/: the entry point used by the domain layerremote/: remote access such as HTTP API, OIDC, or external serviceslocal/: local persistence such as storage, cache, or database-like accessmodel/: request and response shapes used across the feature
Some architectures separate request, response, DAO, entity, and API classes more explicitly. This web repository is lighter, but the same intent still applies: keep transport, persistence, and model concerns separated by feature.
Repository
The repository is the main entry point from the domain layer into the data layer.
Repositoryis the abstraction consumed by the domain layerRepositoryImplis the concrete class that fulfills that abstraction
The repository is responsible for:
- exposing feature-level operations
- hiding source-specific details
- coordinating one or more underlying sources
- deciding when to use local or remote access
- shaping returned data for the rest of the app
- keeping data access centralized for one feature
In practice, the domain layer should depend on the repository abstraction, while dependency injection wires the implementation.
Example from this repository:
ts
import type { AuthGetLoginUrl, AuthProfile, AuthRefreshToken, AuthVerifyLogin } from '../model'
export interface AuthRepository {
getLoginUrl(): Promise<AuthGetLoginUrl>
verifyLogin(url: URL, state: string, verifier: string): Promise<AuthVerifyLogin>
refreshToken(token: string): Promise<AuthRefreshToken>
profile(token: string, subject: string): Promise<AuthProfile>
getLogoutUrl(IdToken: string): Promise<URL>
}ts
import type { AuthOidc } from '../oidc'
import type { AuthRepository } from './AuthRepository'
export class AuthRepositoryImpl implements AuthRepository {
private readonly authOidc: AuthOidc
constructor({ authOidc }: { authOidc: AuthOidc }) {
this.authOidc = authOidc
}
async getLoginUrl() {
return await this.authOidc.getLoginUrl()
}
async verifyLogin(url: URL, state: string, verifier: string) {
return await this.authOidc.verifyLogin(url, state, verifier)
}
async refreshToken(token: string) {
return await this.authOidc.refreshToken(token)
}
async profile(token: string, subject: string) {
return await this.authOidc.profile(token, subject)
}
async getLogoutUrl(idToken: string) {
return await this.authOidc.getLogoutUrl(idToken)
}
}ts
import type { AuthGetLoginUrl, AuthProfile, AuthRefreshToken, AuthVerifyLogin } from '../model'
export interface AuthOidc {
getLoginUrl(): Promise<AuthGetLoginUrl>
verifyLogin(url: URL, state: string, verifier: string): Promise<AuthVerifyLogin>
refreshToken(token: string): Promise<AuthRefreshToken>
profile(token: string, subject: string): Promise<AuthProfile>
getLogoutUrl(IdToken: string): Promise<URL>
}ts
import {
authorizationCodeGrant,
buildAuthorizationUrl,
buildEndSessionUrl,
calculatePKCECodeChallenge,
Configuration,
discovery,
fetchUserInfo,
randomPKCECodeVerifier,
randomState,
refreshTokenGrant,
} from 'openid-client'
import type { Config } from '@/core'
import type { AuthOidc } from './AuthOidc'
export class AuthOidcImpl implements AuthOidc {
private readonly config: Config
constructor({ config }: { config: Config }) {
this.config = config
}
private async getConfig() {
const getDiscovery = await discovery(new URL(this.config.API_URL), this.config.AUTH_CLIENT_ID)
return new Configuration(getDiscovery.serverMetadata(), this.config.AUTH_CLIENT_ID)
}
async getLoginUrl() {
const config = await this.getConfig()
const codeVerifier = randomPKCECodeVerifier()
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier)
const state = randomState()
const url = buildAuthorizationUrl(config, {
state,
redirect_uri: `${this.config.APP_URL}/auth/callback`,
response_type: 'code',
scope: this.config.AUTH_SCOPE,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
})
return {
url,
state,
codeVerifier,
codeChallenge,
}
}
async verifyLogin(url: URL, state: string, verifier: string) {
const config = await this.getConfig()
const response = await authorizationCodeGrant(config, url, {
pkceCodeVerifier: verifier,
expectedState: state,
idTokenExpected: true,
})
return {
idToken: response.id_token!,
subject: response.claims()!.sub,
accessToken: response.access_token,
accessTokenExpiresIn: response.expiresIn() || 60,
refreshToken: response.refresh_token!,
}
}
async refreshToken(token: string) {
const config = await this.getConfig()
const response = await refreshTokenGrant(config, token, {
scope: this.config.AUTH_SCOPE,
})
return {
idToken: response.id_token!,
subject: response.claims()!.sub,
accessToken: response.access_token,
accessTokenExpiresIn: response.expiresIn() || 60,
refreshToken: response.refresh_token!,
}
}
async profile(token: string, subject: string) {
const config = await this.getConfig()
return await fetchUserInfo(config, token, subject)
}
async getLogoutUrl(idToken: string) {
const config = await this.getConfig()
return buildEndSessionUrl(config, {
id_token_hint: idToken,
post_logout_redirect_uri: `${this.config.APP_URL}/auth/logout-callback`,
})
}
}Data Source
The data source is the lower-level part that talks directly to one concrete source.
RemoteorLocalis the source abstractionRemoteImplorLocalImplis the concrete access implementation
The data source is responsible for:
- reading from one source
- writing to one source
- keeping transport or storage details local to that source
- returning source-shaped responses back to the repository
This follows the same general layering approach:
- remote source implementations handle API or external connectivity details
- local source implementations handle persistence details
- repository implementations decide how those sources are combined
- upper layers should not care whether the data came from network, cache, preference, or database-like storage
In this repository, the term "data source" is implemented through concrete names such as:
TodoRemoteandTodoRemoteImplTodoLocalandTodoLocalImplAuthOidcandAuthOidcImpl
So when reading this project, you can treat:
remote/*as remote data sourceslocal/*as local data sources- source-specific folders like
oidc/*as specialized data sources
Implementation Responsibilities
One of the most important points in this kind of architecture is that the implementation layer is not just a pass-through. It is the place where data work is coordinated.
In this repository, that means RepositoryImpl, RemoteImpl, LocalImpl, and source-specific implementations should own concerns such as:
- choosing the right source for the operation
- mapping source output into feature output
- persisting remote results locally when needed
- checking local state before making a remote request when needed
- hiding HTTP, SDK, storage, and protocol details
Not every feature needs all of those responsibilities. Some repositories only forward to one source. Others coordinate remote and local access together. Both are valid as long as the decision stays inside the data layer.
Models
Models define the request and response shapes used across repository and data source layers.
In this repository, models usually live in model/ and are used to:
- describe request payloads
- describe response payloads
- keep abstractions explicit between layers
Models carry data shape. They should not carry coordination responsibility.
Example from this repository:
ts
export type TodoCreateRequest = {
name: string
dueDate: number
}
export type TodoDetailResponse = {
xid: string
name: string
dueDate: number
version: number
}
export type TodoListResponse = Array<{
xid: string
name: string
dueDate: number
version: number
}>
export type TodoUpdateRequest = {
name: string
dueDate: number
version: number
}ts
export type AuthGetLoginUrl = {
url: URL
state: string
codeVerifier: string
codeChallenge: string
}
export type AuthVerifyLogin = {
subject: string
idToken: string
accessToken: string
accessTokenExpiresIn: number
refreshToken: string
}
export type AuthRefreshToken = {
subject: string
idToken: string
accessToken: string
accessTokenExpiresIn: number
refreshToken: string
}
export type AuthProfile = {
sub: string
email?: string
preferred_username?: string
given_name?: string
family_name?: string
phone_number?: string
}ts
export interface BaseResponse<Data = null> {
code: string
data: Data
message: string
success: boolean
}Source Selection
This architecture treats the repository implementation layer as the place that decides how data is retrieved.
Typical decisions include:
- use remote only
- use local only
- read local first, then fallback to remote
- fetch remote, then persist the result locally
- combine multiple remote or local sources into one feature result
This repository already follows that structure conceptually, even when a feature currently uses only one source or very lightweight local persistence.
How The Layers Work Together
The typical flow is:
- the domain layer calls a repository
- the repository implementation decides which source or sources to use
- a remote or local implementation performs the actual access
- the repository implementation maps or coordinates the result
- the repository returns the feature-level result
For example, TodoRepositoryImpl coordinates both:
ts
import type { TodoCreateRequest, TodoDetailResponse, TodoListResponse, TodoUpdateRequest } from '../model'
export interface TodoRepository {
create(request: TodoCreateRequest): Promise<TodoDetailResponse>
getList(limit: number, skip: number): Promise<TodoListResponse>
getDetail(xid: string): Promise<TodoDetailResponse>
update(xid: string, request: TodoUpdateRequest): Promise<TodoDetailResponse>
delete(xid: string): Promise<void>
saveKeyword(keyword: string): Promise<void>
getListSavedKeyword(): Promise<string[]>
}ts
import type { TodoLocal } from '../local'
import type { TodoCreateRequest, TodoUpdateRequest } from '../model'
import type { TodoRemote } from '../remote'
import type { TodoRepository } from './TodoRepository'
export class TodoRepositoryImpl implements TodoRepository {
private readonly todoRemote: TodoRemote
private readonly todoLocal: TodoLocal
constructor({ todoLocal, todoRemote }: { todoLocal: TodoLocal; todoRemote: TodoRemote }) {
this.todoLocal = todoLocal
this.todoRemote = todoRemote
}
async create(request: TodoCreateRequest) {
const response = await this.todoRemote.create(request)
return response.data
}
async getList(limit: number, skip: number) {
const response = await this.todoRemote.getList(limit, skip)
return response.data
}
async getDetail(xid: string) {
const response = await this.todoRemote.getDetail(xid)
return response.data
}
async update(xid: string, request: TodoUpdateRequest) {
const response = await this.todoRemote.update(xid, request)
return response.data
}
async delete(xid: string) {
await this.todoRemote.delete(xid)
}
async saveKeyword(keyword: string) {
await this.todoLocal.saveKeyword(keyword)
}
async getListSavedKeyword() {
return await this.todoLocal.getListSavedKeyword()
}
}ts
import type { BaseResponse } from '../../shared'
import type { TodoCreateRequest, TodoDetailResponse, TodoListResponse, TodoUpdateRequest } from '../model'
export interface TodoRemote {
create(request: TodoCreateRequest): Promise<BaseResponse<TodoDetailResponse>>
getList(limit: number, skip: number): Promise<BaseResponse<TodoListResponse>>
getDetail(xid: string): Promise<BaseResponse<TodoDetailResponse>>
update(xid: string, request: TodoUpdateRequest): Promise<BaseResponse<TodoDetailResponse>>
delete(xid: string): Promise<BaseResponse>
}ts
import type { Config, Http } from '@/core'
import type { BaseResponse } from '../../shared'
import type { TodoCreateRequest, TodoDetailResponse, TodoListResponse, TodoUpdateRequest } from '../model'
import type { TodoRemote } from './TodoRemote'
export class TodoRemoteImpl implements TodoRemote {
private readonly config: Config
private readonly http: Http
constructor({ config, http }: { config: Config; http: Http }) {
this.config = config
this.http = http
}
async create(request: TodoCreateRequest) {
return await this.http.post<BaseResponse<TodoDetailResponse>, never, TodoCreateRequest>({
url: `${this.config.API_URL}/todos`,
data: request,
})
}
async getList(limit: number, skip: number) {
return await this.http.get<BaseResponse<TodoListResponse>, { limit: number; skip: number }>({
url: `${this.config.API_URL}/todos`,
query: {
limit,
skip,
},
})
}
async getDetail(xid: string) {
return await this.http.get<BaseResponse<TodoDetailResponse>>({
url: `${this.config.API_URL}/todos/${xid}`,
})
}
async update(xid: string, request: TodoUpdateRequest) {
return await this.http.post<BaseResponse<TodoDetailResponse>, never, TodoUpdateRequest>({
url: `${this.config.API_URL}/todos/${xid}`,
data: request,
})
}
async delete(xid: string) {
return await this.http.delete<BaseResponse>({
url: `${this.config.API_URL}/todos/${xid}`,
})
}
}ts
export interface TodoLocal {
saveKeyword(keyword: string): Promise<void>
getListSavedKeyword(): Promise<string[]>
}ts
import type { Config, Persistent } from '../../../adapter'
import type { TodoLocal } from './TodoLocal'
export class TodoLocalImpl implements TodoLocal {
private readonly persistent: Persistent
private readonly config: Config
constructor({ persistent, config }: { persistent: Persistent; config: Config }) {
this.persistent = persistent
this.config = config
}
async saveKeyword(keyword: string) {
const normalizedKeyword = keyword.trim()
if (!normalizedKeyword) return
const savedKeywords = await this.getListSavedKeyword()
if (savedKeywords.includes(normalizedKeyword)) return
try {
this.persistent.set(this.config.TODO_SAVED_KEYWORDS_KEY, [normalizedKeyword, ...savedKeywords])
} catch (error) {
console.error('Failed to save todo keyword:', error)
}
}
async getListSavedKeyword() {
return this.persistent.get<string[]>(this.config.TODO_SAVED_KEYWORDS_KEY) ?? []
}
}Remote methods handle API communication, while local methods handle device-side persistence. The repository is where both are combined into one feature-facing API.
Dependency Direction
The dependency direction in this project should be read like this:
- the domain layer depends on repositories
- repository implementations depend on source abstractions
- source implementations depend on technical details such as HTTP, config, storage, or SDK clients
- models are shared as common shapes across those layers
Consumers should not call RemoteImpl, LocalImpl, HTTP clients, or storage APIs directly when a repository already exists.
Diagram
This diagram shows the static structure used in this repository.
This diagram shows a typical coordination flow inside the data layer.
Summary
In this repository, it is easier to understand the data layer as two main parts:
- the repository for feature-facing access
- the data source for source-facing access
The abstraction defines what is available. The implementation defines how it works. The most important rule is that the domain layer enters through the repository, and the data layer keeps source selection, mapping, caching, persistence, and transport details inside itself.