12 KiB
12 KiB
Recommended Portal Structure: lib/ vs features/
🎯 Core Principle: Co-location
Feature-specific code belongs WITH the feature, not in a centralized lib/ folder.
📂 Proposed Structure
apps/portal/src/
├── lib/ # ✅ ONLY truly generic utilities
│ ├── api/
│ │ ├── client.ts # API client instance & configuration
│ │ ├── query-keys.ts # React Query key factory
│ │ ├── helpers.ts # getDataOrThrow, getDataOrDefault
│ │ └── index.ts # Barrel export
│ │
│ ├── utils/
│ │ ├── cn.ts # Tailwind className utility (used everywhere)
│ │ ├── error-handling.ts # Generic error handling (used everywhere)
│ │ └── index.ts
│ │
│ ├── providers.tsx # Root-level React context providers
│ └── index.ts # Main barrel export
│
└── features/ # ✅ Feature-specific code lives here
├── billing/
│ ├── components/
│ │ ├── InvoiceList.tsx
│ │ └── InvoiceCard.tsx
│ ├── hooks/
│ │ └── useBilling.ts # ← Feature-specific hooks HERE
│ ├── utils/
│ │ └── invoice-helpers.ts # ← Feature-specific utilities HERE
│ └── index.ts
│
├── subscriptions/
│ ├── components/
│ ├── hooks/
│ │ └── useSubscriptions.ts
│ └── index.ts
│
├── orders/
│ ├── components/
│ ├── hooks/
│ │ └── useOrders.ts
│ └── index.ts
│
└── auth/
├── components/
├── hooks/
│ └── useAuth.ts
├── services/
│ └── auth.store.ts
└── index.ts
🎨 What Goes Where?
✅ lib/ - Truly Generic, Reusable Across Features
| File | Purpose | Used By |
|---|---|---|
lib/api/client.ts |
API client instance | All features |
lib/api/query-keys.ts |
React Query keys factory | All features |
lib/api/helpers.ts |
getDataOrThrow, getDataOrDefault |
All features |
lib/utils/cn.ts |
Tailwind className merger | All components |
lib/utils/error-handling.ts |
Generic error parsing | All features |
lib/providers.tsx |
Root providers (QueryClient, Theme) | App root |
✅ features/*/hooks/ - Feature-Specific Hooks
| File | Purpose | Used By |
|---|---|---|
features/billing/hooks/useBilling.ts |
Invoice queries & mutations | Billing pages only |
features/subscriptions/hooks/useSubscriptions.ts |
Subscription queries | Subscription pages only |
features/orders/hooks/useOrders.ts |
Order queries | Order pages only |
features/auth/hooks/useAuth.ts |
Auth state & actions | Auth-related components |
❌ Anti-Pattern: Centralized Feature Hooks
lib/
├── hooks/
│ ├── use-billing.ts # ❌ BAD - billing-specific
│ ├── use-subscriptions.ts # ❌ BAD - subscriptions-specific
│ └── use-orders.ts # ❌ BAD - orders-specific
Why this is bad:
- Hard to find (is it in
liborfeatures?) - Breaks feature encapsulation
- Harder to delete features
- Makes the
libfolder bloated
📝 Implementation
1. lib/api/index.ts - Clean API Exports
/**
* API Client Utilities
* Central export for all API-related functionality
*/
export { apiClient } from "./client";
export { queryKeys } from "./query-keys";
export { getDataOrThrow, getDataOrDefault, isApiError } from "./helpers";
// Re-export common types from generated client
export type { QueryParams, PathParams } from "./runtime/client";
2. lib/api/helpers.ts - API Helper Functions
import type { ApiResponse } from "@customer-portal/domain/common";
export function getDataOrThrow<T>(
response: { data?: T; error?: unknown },
errorMessage: string
): T {
if (response.error || !response.data) {
throw new Error(errorMessage);
}
return response.data;
}
export function getDataOrDefault<T>(
response: { data?: T; error?: unknown },
defaultValue: T
): T {
return response.data ?? defaultValue;
}
export function isApiError(error: unknown): error is Error {
return error instanceof Error;
}
3. features/billing/hooks/useBilling.ts - Clean Hook Implementation
"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient, queryKeys, getDataOrThrow, getDataOrDefault } from "@/lib/api";
import type { QueryParams } from "@/lib/api";
// ✅ Single consolidated import from domain
import {
// Types
type Invoice,
type InvoiceList,
type InvoiceQueryParams,
type InvoiceSsoLink,
type PaymentMethodList,
// Schemas
invoiceSchema,
invoiceListSchema,
// Constants
INVOICE_STATUS,
type InvoiceStatus,
} from "@customer-portal/domain/billing";
// Constants
const EMPTY_INVOICE_LIST: InvoiceList = {
invoices: [],
pagination: { page: 1, totalItems: 0, totalPages: 0 },
};
const EMPTY_PAYMENT_METHODS: PaymentMethodList = {
paymentMethods: [],
totalCount: 0,
};
// Helper functions
function ensureInvoiceStatus(invoice: Invoice): Invoice {
return {
...invoice,
status: (invoice.status as InvoiceStatus) ?? INVOICE_STATUS.DRAFT,
};
}
function normalizeInvoiceList(list: InvoiceList): InvoiceList {
return {
...list,
invoices: list.invoices.map(ensureInvoiceStatus),
pagination: {
page: list.pagination?.page ?? 1,
totalItems: list.pagination?.totalItems ?? 0,
totalPages: list.pagination?.totalPages ?? 0,
nextCursor: list.pagination?.nextCursor,
},
};
}
function toQueryParams(params: InvoiceQueryParams): QueryParams {
return Object.entries(params).reduce((acc, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
}, {} as QueryParams);
}
// API functions (keep as internal implementation details)
async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
const query = params ? toQueryParams(params) : undefined;
const response = await apiClient.GET<InvoiceList>(
"/api/invoices",
query ? { params: { query } } : undefined
);
const data = getDataOrDefault(response, EMPTY_INVOICE_LIST);
const parsed = invoiceListSchema.parse(data);
return normalizeInvoiceList(parsed);
}
async function fetchInvoice(id: string): Promise<Invoice> {
const response = await apiClient.GET<Invoice>("/api/invoices/{id}", {
params: { path: { id } },
});
const invoice = getDataOrThrow(response, "Invoice not found");
const parsed = invoiceSchema.parse(invoice);
return ensureInvoiceStatus(parsed);
}
async function fetchPaymentMethods(): Promise<PaymentMethodList> {
const response = await apiClient.GET<PaymentMethodList>("/api/invoices/payment-methods");
return getDataOrDefault(response, EMPTY_PAYMENT_METHODS);
}
// Exported hooks
export function useInvoices(params?: InvoiceQueryParams) {
return useQuery({
queryKey: queryKeys.billing.invoices(params),
queryFn: () => fetchInvoices(params),
});
}
export function useInvoice(id: string, enabled = true) {
return useQuery({
queryKey: queryKeys.billing.invoice(id),
queryFn: () => fetchInvoice(id),
enabled: Boolean(id) && enabled,
});
}
export function usePaymentMethods() {
return useQuery({
queryKey: queryKeys.billing.paymentMethods(),
queryFn: fetchPaymentMethods,
});
}
export function useCreateInvoiceSsoLink() {
return useMutation({
mutationFn: async ({
invoiceId,
target
}: {
invoiceId: number;
target?: "view" | "download" | "pay"
}) => {
const response = await apiClient.POST<InvoiceSsoLink>("/api/invoices/{id}/sso-link", {
params: {
path: { id: invoiceId },
query: target ? { target } : undefined,
},
});
return getDataOrThrow(response, "Failed to create SSO link");
},
});
}
export function useCreatePaymentMethodsSsoLink() {
return useMutation({
mutationFn: async () => {
const response = await apiClient.POST<InvoiceSsoLink>("/auth/sso-link", {
body: { destination: "index.php?rp=/account/paymentmethods" },
});
return getDataOrThrow(response, "Failed to create payment methods SSO link");
},
});
}
4. features/billing/index.ts - Feature Barrel Export
/**
* Billing Feature Exports
*/
// Hooks
export * from "./hooks/useBilling";
// Components (if you want to export them)
export * from "./components/InvoiceList";
export * from "./components/InvoiceCard";
5. lib/index.ts - Generic Utilities Only
/**
* Portal Library
* ONLY generic utilities used across features
*/
// API utilities (used by all features)
export * from "./api";
// Generic utilities (used by all features)
export * from "./utils";
// NOTE: Feature-specific hooks are NOT exported here
// Import them from their respective features:
// import { useInvoices } from "@/features/billing";
🎯 Usage After Restructure
In Billing Pages/Components
// ✅ Import from feature
import { useInvoices, useInvoice, usePaymentMethods } from "@/features/billing";
// Or direct import
import { useInvoices } from "@/features/billing/hooks/useBilling";
function InvoicesPage() {
const { data: invoices, isLoading } = useInvoices({ status: "Unpaid" });
// ...
}
In Feature Hooks (within features/billing/hooks/)
// ✅ Import generic utilities from lib
import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api";
// ✅ Import domain types
import {
type Invoice,
type InvoiceList,
invoiceSchema,
type InvoiceQueryParams
} from "@customer-portal/domain/billing";
export function useInvoices(params?: InvoiceQueryParams) {
// ...
}
In Other Feature Hooks (features/subscriptions/hooks/)
// ✅ Generic utilities from lib
import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api";
// ✅ Domain types for subscriptions
import {
type Subscription,
subscriptionSchema,
type SubscriptionQueryParams
} from "@customer-portal/domain/subscriptions";
export function useSubscriptions(params?: SubscriptionQueryParams) {
// ...
}
📋 Benefits of This Structure
1. Clear Separation of Concerns
api/- HTTP client & infrastructurehooks/- React Query abstractionsutils/- Helper functions
2. Clean Imports
// ❌ Before: Messy
import { apiClient, queryKeys, getDataOrDefault, getDataOrThrow } from "@/lib/api";
import type { QueryParams } from "@/lib/api/runtime/client";
import { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import { invoiceSchema } from "@customer-portal/domain/validation/shared/entities";
import { INVOICE_STATUS } from "@customer-portal/domain/billing";
// ✅ After: Clean
import { apiClient, queryKeys, getDataOrThrow, type QueryParams } from "@/lib/api";
import {
type Invoice,
type InvoiceList,
invoiceSchema,
INVOICE_STATUS,
} from "@customer-portal/domain/billing";
3. Easy to Find Things
- Need a query hook? →
lib/hooks/queries/ - Need API utilities? →
lib/api/ - Need to update a domain type? →
packages/domain/billing/
4. Testable
Each piece can be tested independently:
- API helpers are pure functions
- Hooks can be tested with React Testing Library
- Domain logic is already in domain package
🔧 What About That Weird Validation Import?
// ❌ This should NOT exist
import { invoiceSchema } from "@customer-portal/domain/validation/shared/entities";
This path suggests you might have old validation code. The schema should be:
// ✅ Correct
import { invoiceSchema } from "@customer-portal/domain/billing";
Let me check if this old validation path exists and needs cleanup.