Assist_Design/docs/decisions/005-feature-module-pattern.md

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?

  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?

// ✅ 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 />;
}