154 lines
4.6 KiB
Markdown
154 lines
4.6 KiB
Markdown
|
|
# 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?
|
||
|
|
|
||
|
|
1. **Cohesion**: All billing-related code is in one place. Need to modify billing? Look in `features/billing/`.
|
||
|
|
|
||
|
|
2. **Scalability**: Adding a new feature = adding a new folder. No need to touch multiple scattered directories.
|
||
|
|
|
||
|
|
3. **Clear ownership**: Easy to understand what a feature encompasses.
|
||
|
|
|
||
|
|
4. **Encapsulation**: Features export a public API via `index.ts`. Internal implementation details are hidden.
|
||
|
|
|
||
|
|
### Why Thin Pages?
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ✅ 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// app/account/billing/invoices/page.tsx
|
||
|
|
import { InvoicesListView } from "@/features/billing";
|
||
|
|
|
||
|
|
export default function InvoicesPage() {
|
||
|
|
return <InvoicesListView />;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Related
|
||
|
|
|
||
|
|
- [Portal Architecture](../development/portal/architecture.md)
|
||
|
|
- [ADR-006: Thin Controllers](./006-thin-controllers.md)
|