refactor: add DI service layer for projects, orgs, profile, and permissions

Introduces service interfaces and live wiring for the four services that
back layout-level data dependencies, then migrates their hooks to read
from the registry instead of calling fetchers directly.

New files per service:
- *-service.ts       — typed interface (ProjectsService, OrganizationsService,
                       ProfileService, PermissionsService)
- *-service-live.ts  — live impl that delegates to the existing async fetcher
                       functions (getProjectDetail, getOrganizations, etc.),
                       which are themselves unchanged

Registry and live-registry updated to include all four services.

Hook migrations (no fallback — hooks always use the registry):
- useProjectDetailQuery     → useService('projects').getProjectDetail
- useOrganizationsQuery     → useService('organizations').getOrganizations
- useProfileQuery           → useService('profile').getProfile
- usePermissionsQuery       → useService('permissions').getPermissions

prefetchProjectDetail gains a required service parameter so prefetch paths
also go through the registry. Its single caller (usePrefetchProjectIndexPage)
supplies the service via useService('projects').

No behaviour change in production — live implementations delegate to the
same fetcher functions as before.
This commit is contained in:
Charis Lam committed 2026-03-03 13:45:04 -05:00
1 parent 30f6660e68
commit edc030d38b
15 files changed
+104 -27

No files matched your search

@@ -1,11 +1,12 @@
import { QueryClient, useQuery } from '@tanstack/react-query'
import { components } from 'api-types'
import { get, handleError } from 'data/fetchers'
import { MANAGED_BY, ManagedBy } from 'lib/constants/infrastructure'
import { useProfile } from 'lib/profile'
import type { Organization, ResponseError, UseCustomQueryOptions } from 'types'
import { organizationKeys } from './keys'
import { get, handleError } from '@/data/fetchers'
import { MANAGED_BY, ManagedBy } from '@/lib/constants/infrastructure'
import { useProfile } from '@/lib/profile'
import { useService } from '@/lib/services/context'
import type { Organization, ResponseError, UseCustomQueryOptions } from '@/types'
export type OrganizationBase = components['schemas']['OrganizationResponse']
@@ -54,9 +55,10 @@ export const useOrganizationsQuery = <TData = OrganizationsData>({
...options
}: UseCustomQueryOptions<OrganizationsData, OrganizationsError, TData> = {}) => {
const { profile } = useProfile()
const { getOrganizations: fetchOrganizations } = useService('organizations')
return useQuery<OrganizationsData, OrganizationsError, TData>({
queryKey: organizationKeys.list(),
queryFn: ({ signal }) => getOrganizations({ signal }),
queryFn: ({ signal }) => fetchOrganizations({ signal }),
enabled: enabled && profile !== undefined,
...options,
staleTime: 30 * 60 * 1000,
@@ -0,0 +1,6 @@
import { getOrganizations } from './organizations-query'
import type { OrganizationsService } from './organizations-service'
export const organizationsServiceLive: OrganizationsService = {
getOrganizations,
}
@@ -0,0 +1,5 @@
import type { Organization } from '@/types'
export interface OrganizationsService {
getOrganizations: (params: { signal?: AbortSignal }) => Promise<Organization[]>
}
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { useIsLoggedIn } from 'common'
import { get, handleError } from 'data/fetchers'
import { IS_PLATFORM } from 'lib/constants'
import type { Permission, ResponseError, UseCustomQueryOptions } from 'types'
import { permissionKeys } from './keys'
import { get, handleError } from '@/data/fetchers'
import { IS_PLATFORM } from '@/lib/constants'
import { useService } from '@/lib/services/context'
import type { Permission, ResponseError, UseCustomQueryOptions } from '@/types'
export type PermissionsResponse = Permission[]
@@ -35,10 +36,11 @@ export const usePermissionsQuery = <TData = PermissionsData>({
...options
}: UseCustomQueryOptions<PermissionsData, PermissionsError, TData> = {}) => {
const isLoggedIn = useIsLoggedIn()
const { getPermissions: fetchPermissions } = useService('permissions')
return useQuery<PermissionsData, PermissionsError, TData>({
queryKey: permissionKeys.list(),
queryFn: ({ signal }) => getPermissions(signal),
queryFn: ({ signal }) => fetchPermissions(signal),
...options,
enabled: IS_PLATFORM && enabled && isLoggedIn,
staleTime: 5 * 60 * 1000,
@@ -0,0 +1,6 @@
import { getPermissions } from './permissions-query'
import type { PermissionsService } from './permissions-service'
export const permissionsServiceLive: PermissionsService = {
getPermissions,
}
@@ -0,0 +1,7 @@
import type { Permission } from 'types'
export type PermissionsResponse = Permission[]
export interface PermissionsService {
getPermissions: (signal?: AbortSignal) => Promise<PermissionsResponse>
}
@@ -3,11 +3,13 @@ import { useRouter } from 'next/router'
import { PropsWithChildren, useCallback } from 'react'
import { prefetchProjectDetail } from 'data/projects/project-detail-query'
import { useService } from 'lib/services/context'
import PrefetchableLink, { PrefetchableLinkProps } from './PrefetchableLink'
export function usePrefetchProjectIndexPage() {
const router = useRouter()
const queryClient = useQueryClient()
const projectsService = useService('projects')
return useCallback(
({ projectRef }: { projectRef?: string }) => {
@@ -15,13 +17,11 @@ export function usePrefetchProjectIndexPage() {
router.prefetch(`/project/${projectRef}`)
// Prefetch data
prefetchProjectDetail(queryClient, {
ref: projectRef,
}).catch(() => {
prefetchProjectDetail(queryClient, { ref: projectRef }, projectsService).catch(() => {
// eat prefetching errors as they are not critical
})
},
[queryClient, router]
[queryClient, router, projectsService]
)
}
+6 -4
View File
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { IS_PLATFORM } from 'lib/constants'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { profileKeys } from './keys'
import type { Profile } from './types'
import { get, handleError } from '@/data/fetchers'
import { IS_PLATFORM } from '@/lib/constants'
import { useService } from '@/lib/services/context'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export async function getProfile(signal?: AbortSignal) {
const { data, error } = await get('/platform/profile', {
@@ -31,9 +32,10 @@ export const useProfileQuery = <TData = ProfileData>({
enabled = true,
...options
}: UseCustomQueryOptions<ProfileData, ProfileError, TData> = {}) => {
const { getProfile: fetchProfile } = useService('profile')
return useQuery<ProfileData, ProfileError, TData>({
queryKey: profileKeys.profile(),
queryFn: ({ signal }) => getProfile(signal),
queryFn: ({ signal }) => fetchProfile(signal),
staleTime: 1000 * 60 * 30,
...options,
enabled,
@@ -0,0 +1,6 @@
import { getProfile } from './profile-query'
import type { ProfileService } from './profile-service'
export const profileServiceLive: ProfileService = {
getProfile,
}
@@ -0,0 +1,5 @@
import type { Profile } from './types'
export interface ProfileService {
getProfile: (signal?: AbortSignal) => Promise<Profile>
}
@@ -1,11 +1,13 @@
import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import type { components } from 'data/api'
import { get, handleError, isValidConnString } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { projectKeys } from './keys'
import { OrgProjectsResponse } from './org-projects-infinite-query'
import type { ProjectsService } from './projects-service'
import type { components } from '@/data/api'
import { get, handleError, isValidConnString } from '@/data/fetchers'
import { useService } from '@/lib/services/context'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
type ProjectDetailVariables = { ref?: string }
export type ProjectDetail = components['schemas']['ProjectDetailResponse']
@@ -45,10 +47,12 @@ export const useProjectDetailQuery = <TData = ProjectDetailData>(
enabled = true,
...options
}: UseCustomQueryOptions<ProjectDetailData, ProjectDetailError, TData> = {}
) =>
useQuery<ProjectDetailData, ProjectDetailError, TData>({
) => {
const { getProjectDetail: fetchProjectDetail } = useService('projects')
return useQuery<ProjectDetailData, ProjectDetailError, TData>({
queryKey: projectKeys.detail(ref),
queryFn: ({ signal }) => getProjectDetail({ ref }, signal),
queryFn: ({ signal }) => fetchProjectDetail({ ref }, signal),
enabled: enabled && typeof ref !== 'undefined',
staleTime: 30 * 1000,
refetchInterval: (query) => {
@@ -64,11 +68,16 @@ export const useProjectDetailQuery = <TData = ProjectDetailData>(
},
...options,
})
}
export function prefetchProjectDetail(client: QueryClient, { ref }: ProjectDetailVariables) {
export function prefetchProjectDetail(
client: QueryClient,
{ ref }: ProjectDetailVariables,
service: { getProjectDetail: ProjectsService['getProjectDetail'] }
) {
return client.fetchQuery({
queryKey: projectKeys.detail(ref),
queryFn: ({ signal }) => getProjectDetail({ ref }, signal),
queryFn: ({ signal }) => service.getProjectDetail({ ref }, signal),
})
}
@@ -0,0 +1,6 @@
import { getProjectDetail } from './project-detail-query'
import type { ProjectsService } from './projects-service'
export const projectsServiceLive: ProjectsService = {
getProjectDetail,
}
@@ -0,0 +1,5 @@
import type { Project } from './project-detail-query'
export interface ProjectsService {
getProjectDetail: (params: { ref?: string }, signal?: AbortSignal) => Promise<Project>
}
@@ -1,10 +1,18 @@
import { authServiceLive } from './auth-service'
import { featureFlagServiceLive } from './feature-flag-service'
import type { ServiceRegistry } from './registry'
import { organizationsServiceLive } from '@/data/organizations/organizations-service-live'
import { permissionsServiceLive } from '@/data/permissions/permissions-service-live'
import { profileServiceLive } from '@/data/profile/profile-service-live'
import { projectsServiceLive } from '@/data/projects/projects-service-live'
export function createLiveRegistry(): ServiceRegistry {
return {
auth: authServiceLive,
featureFlags: featureFlagServiceLive,
organizations: organizationsServiceLive,
permissions: permissionsServiceLive,
profile: profileServiceLive,
projects: projectsServiceLive,
}
}
+8
View File
@@ -1,5 +1,9 @@
import type { AuthService } from './auth-service'
import type { FeatureFlagService } from './feature-flag-service'
import type { OrganizationsService } from '@/data/organizations/organizations-service'
import type { PermissionsService } from '@/data/permissions/permissions-service'
import type { ProfileService } from '@/data/profile/profile-service'
import type { ProjectsService } from '@/data/projects/projects-service'
/**
* Central registry of all injectable services.
@@ -9,4 +13,8 @@ import type { FeatureFlagService } from './feature-flag-service'
export interface ServiceRegistry {
auth: AuthService
featureFlags: FeatureFlagService
organizations: OrganizationsService
permissions: PermissionsService
profile: ProfileService
projects: ProjectsService
}