4.6 KiB
4.6 KiB
ADR-005: Feature Module Pattern
Date: 2025-01-15 Status: Accepted
Context
The Portal (Next.js frontend) needs a scalable organization pattern. Common approaches:
- File-type grouping: All components in
/components, all hooks in/hooks - Feature grouping: All billing code in
/features/billing
Decision
Organize Portal code by feature modules with consistent internal structure:
apps/portal/src/features/billing/
├── api/ # Data fetching (billing.api.ts)
├── hooks/ # React Query hooks (useBilling.ts)
├── stores/ # Zustand state (if needed)
├── components/ # Feature UI (InvoiceList.tsx)
├── views/ # Page-level views (InvoicesList.tsx)
└── index.ts # Barrel exports
Pages in app/ are thin wrappers that import views from features.
Rationale
Why Feature Modules?
-
Cohesion: All billing-related code is in one place. Need to modify billing? Look in
features/billing/. -
Scalability: Adding a new feature = adding a new folder. No need to touch multiple scattered directories.
-
Clear ownership: Easy to understand what a feature encompasses.
-
Encapsulation: Features export a public API via
index.ts. Internal implementation details are hidden.
Why Thin Pages?
// ✅ GOOD: Page is a thin wrapper
// app/account/billing/invoices/page.tsx
import { InvoicesListView } from "@/features/billing/views";
export default function InvoicesPage() {
return <InvoicesListView />;
}
// ❌ BAD: Page contains logic
export default function InvoicesPage() {
const invoices = useInvoices(); // Data fetching in page
return <InvoiceTable data={invoices} />; // Logic in page
}
Benefits of thin pages:
- Pages are declarative route definitions
- Business logic is testable in isolation
- Views can be reused across routes if needed
Alternatives Considered
| Approach | Pros | Cons |
|---|---|---|
| File-type grouping | Familiar, simple | Poor cohesion, hard to find related code |
| Domain-driven | Clean boundaries | Overkill for frontend |
| Feature modules | High cohesion, scalable | Requires discipline |
Consequences
Positive
- High cohesion: all related code together
- Easy to add new features
- Clear public API per feature
- Pages remain declarative
Negative
- Slightly more initial setup per feature
- Requires consistent discipline across team
Implementation
Feature Module Structure
features/[feature-name]/
├── api/ # Data fetching layer
│ ├── [feature].api.ts # API service functions
│ └── index.ts # Re-export
├── hooks/ # React Query hooks
│ ├── use[Feature].ts # Primary hook
│ └── index.ts
├── stores/ # Zustand stores (if needed)
│ └── [feature].store.ts
├── components/ # Feature-specific UI
│ ├── [Component].tsx
│ └── index.ts
├── views/ # Page-level views
│ └── [Feature]View.tsx
├── utils/ # Feature utilities
└── index.ts # Public API (barrel export)
Example: Billing Feature
// features/billing/api/billing.api.ts
export const billingService = {
getInvoices: async (params) => apiClient.GET("/api/invoices", { params }),
getInvoice: async (id) => apiClient.GET(`/api/invoices/${id}`),
};
// features/billing/hooks/useBilling.ts
export function useInvoices(params?: InvoiceQueryParams) {
return useQuery({
queryKey: queryKeys.billing.invoices(params),
queryFn: () => billingService.getInvoices(params),
});
}
// features/billing/views/InvoicesList.tsx
export function InvoicesListView() {
const { data: invoices } = useInvoices();
return <InvoiceTable invoices={invoices} />;
}
// features/billing/index.ts (public API)
export { billingService } from "./api";
export { useInvoices, useInvoice } from "./hooks";
export { InvoicesListView } from "./views";
Page Usage
// app/account/billing/invoices/page.tsx
import { InvoicesListView } from "@/features/billing";
export default function InvoicesPage() {
return <InvoicesListView />;
}