feat: add architecture decision records for key design choices
This commit is contained in:
parent
37ef51cc82
commit
26502b4356
80
docs/decisions/001-platform-events-over-webhooks.md
Normal file
80
docs/decisions/001-platform-events-over-webhooks.md
Normal file
@ -0,0 +1,80 @@
|
||||
# ADR-001: Platform Events over Webhooks
|
||||
|
||||
**Date**: 2025-01-15
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The Customer Portal needs to trigger order provisioning when orders are approved in Salesforce. Two main approaches exist:
|
||||
|
||||
1. **Inbound webhooks**: Salesforce calls a BFF endpoint when order status changes
|
||||
2. **Platform Events**: BFF subscribes to Salesforce Platform Events and reacts to published events
|
||||
|
||||
## Decision
|
||||
|
||||
Use **Salesforce Platform Events** for order provisioning triggers instead of inbound webhooks.
|
||||
|
||||
The BFF subscribes to `OrderProvisionRequested__e` Platform Events. When an operator approves an order in Salesforce, a Record-Triggered Flow publishes this event, and the BFF subscriber enqueues a provisioning job.
|
||||
|
||||
## Rationale
|
||||
|
||||
### Why Platform Events?
|
||||
|
||||
1. **No public endpoint exposure**: Webhooks require exposing a public endpoint that accepts requests from Salesforce. This creates attack surface and requires:
|
||||
- IP allowlisting of Salesforce egress ranges
|
||||
- Request signature validation
|
||||
- CSRF protection
|
||||
- Rate limiting
|
||||
|
||||
2. **Pull vs Push model**: Platform Events use a pull model where BFF controls when to fetch events. This provides better:
|
||||
- Backpressure handling
|
||||
- Retry control
|
||||
- Rate management
|
||||
|
||||
3. **Reliability**: Salesforce Platform Events have built-in replay capability. If the BFF is down, events are retained and can be replayed when it comes back up.
|
||||
|
||||
4. **Simpler security**: The BFF authenticates to Salesforce (outbound) rather than validating inbound requests.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
| ------------------- | ------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| **Webhooks** | Immediate notification, simpler Salesforce setup | Public endpoint, security complexity, no replay |
|
||||
| **Polling** | No endpoint needed, simple | Latency, wasted API calls, inefficient |
|
||||
| **Platform Events** | Secure, reliable, replay support | Requires SF Platform Events license, slightly more complex subscription |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- No public endpoints for external systems to call
|
||||
- Built-in event replay for reliability
|
||||
- BFF controls processing rate
|
||||
- Simpler security model (no signature validation)
|
||||
|
||||
### Negative
|
||||
|
||||
- Requires Salesforce Platform Events feature (licensing)
|
||||
- Slightly more complex initial setup
|
||||
- Events may have delivery delay vs synchronous webhooks
|
||||
|
||||
## Implementation
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Salesforce │ │ Platform Event │ │ BFF │
|
||||
│ (Approval) │───▶│ OrderProvision │◀───│ (Subscriber) │
|
||||
│ │ │ Requested__e │ │ │
|
||||
└─────────────┘ └──────────────────┘ └────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ BullMQ Job │
|
||||
│ (Provisioning) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Platform Events Integration](../integrations/salesforce/platform-events.md)
|
||||
- [Order Fulfillment](../how-it-works/order-fulfillment.md)
|
||||
- [Modular Provisioning](../architecture/modular-provisioning.md)
|
||||
129
docs/decisions/002-zod-first-validation.md
Normal file
129
docs/decisions/002-zod-first-validation.md
Normal file
@ -0,0 +1,129 @@
|
||||
# ADR-002: Zod-First Validation
|
||||
|
||||
**Date**: 2025-01-15
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The Customer Portal needs validation at multiple layers:
|
||||
|
||||
- BFF request/response validation
|
||||
- Domain type validation
|
||||
- Portal form validation
|
||||
|
||||
Traditional approaches use separate validation at each layer (class-validator in NestJS, yup/joi in React, manual TypeScript types). This leads to:
|
||||
|
||||
- Duplicated validation logic
|
||||
- Type/validation drift
|
||||
- Inconsistent error messages
|
||||
|
||||
## Decision
|
||||
|
||||
Use **Zod schemas as the single source of truth** for both TypeScript types and runtime validation across all layers.
|
||||
|
||||
```typescript
|
||||
// Schema defines both type AND validation
|
||||
export const invoiceSchema = z.object({
|
||||
id: z.number(),
|
||||
status: z.enum(["paid", "unpaid", "overdue"]),
|
||||
amount: z.number().positive(),
|
||||
});
|
||||
|
||||
// Type derived from schema - always in sync
|
||||
export type Invoice = z.infer<typeof invoiceSchema>;
|
||||
```
|
||||
|
||||
## Rationale
|
||||
|
||||
### Why Zod?
|
||||
|
||||
1. **Single source of truth**: Schema defines both TypeScript type and runtime validation. No drift possible.
|
||||
|
||||
2. **Cross-layer consistency**: Same schema works in:
|
||||
- Domain layer (type definitions)
|
||||
- BFF (request/response validation via `nestjs-zod`)
|
||||
- Portal (form validation via `@hookform/resolvers/zod`)
|
||||
|
||||
3. **Runtime safety**: TypeScript only validates at compile time. Zod validates at runtime for external data (API responses, user input).
|
||||
|
||||
4. **Better DX**:
|
||||
- Composable schemas (`z.extend()`, `z.pick()`, `z.omit()`)
|
||||
- Excellent error messages
|
||||
- Full TypeScript inference
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
| --------------------------------------- | -------------------------------- | ---------------------------------------------------- |
|
||||
| **class-validator + class-transformer** | NestJS native, decorators | Types separate from validation, verbose |
|
||||
| **TypeScript only** | No runtime overhead | No runtime validation, unsafe for external data |
|
||||
| **Joi/Yup** | Mature libraries | Poor TypeScript inference, separate type definitions |
|
||||
| **Zod** | Types from schemas, excellent TS | Slightly newer ecosystem |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Types and validation always in sync
|
||||
- Consistent validation across BFF and Portal
|
||||
- Excellent TypeScript inference
|
||||
- Composable schemas reduce duplication
|
||||
- Runtime safety for external data
|
||||
|
||||
### Negative
|
||||
|
||||
- Slightly larger bundle size vs TypeScript-only
|
||||
- Team needs to learn Zod API
|
||||
- Some NestJS features require `createZodDto()` wrapper
|
||||
|
||||
## Implementation
|
||||
|
||||
### BFF Validation
|
||||
|
||||
```typescript
|
||||
// apps/bff/src/app.module.ts
|
||||
providers: [
|
||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||
]
|
||||
|
||||
// Controller uses DTOs built from domain schemas
|
||||
import { createZodDto } from "nestjs-zod";
|
||||
import { invoiceQuerySchema } from "@customer-portal/domain/billing";
|
||||
|
||||
class InvoiceQueryDto extends createZodDto(invoiceQuerySchema) {}
|
||||
|
||||
@Get()
|
||||
async getInvoices(@Query() query: InvoiceQueryDto) { ... }
|
||||
```
|
||||
|
||||
### Domain Mappers
|
||||
|
||||
```typescript
|
||||
// packages/domain/billing/providers/whmcs/mapper.ts
|
||||
export function transformWhmcsInvoice(raw: unknown): Invoice {
|
||||
const validated = whmcsInvoiceRawSchema.parse(raw); // Validate input
|
||||
const result = {
|
||||
/* transform */
|
||||
};
|
||||
return invoiceSchema.parse(result); // Validate output
|
||||
}
|
||||
```
|
||||
|
||||
### Portal Forms
|
||||
|
||||
```typescript
|
||||
// apps/portal/src/features/auth/components/LoginForm.tsx
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { loginSchema } from "@customer-portal/domain/auth";
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Domain Structure](../development/domain/structure.md)
|
||||
- [BFF Validation](../development/bff/validation.md)
|
||||
- [Integration Patterns](../development/bff/integration-patterns.md)
|
||||
147
docs/decisions/003-map-once-use-everywhere.md
Normal file
147
docs/decisions/003-map-once-use-everywhere.md
Normal file
@ -0,0 +1,147 @@
|
||||
# ADR-003: Map Once, Use Everywhere
|
||||
|
||||
**Date**: 2025-01-15
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The Customer Portal integrates with multiple external systems (WHMCS, Salesforce, Freebit) that each have their own data structures. These raw API responses need to be transformed into normalized domain types.
|
||||
|
||||
Common anti-patterns:
|
||||
|
||||
- Transforming data multiple times at different layers
|
||||
- Creating "transformer services" that wrap domain mappers
|
||||
- Leaking raw provider types into application code
|
||||
|
||||
## Decision
|
||||
|
||||
**Transform external data exactly once** in domain mappers. Everything else uses the normalized domain types directly.
|
||||
|
||||
```
|
||||
External API → Integration Service → Domain Mapper → Domain Type → Use Directly
|
||||
↑
|
||||
SINGLE transformation
|
||||
```
|
||||
|
||||
## Rationale
|
||||
|
||||
### Why Single Transformation?
|
||||
|
||||
1. **No drift**: One transformation = one place where the mapping can be wrong
|
||||
|
||||
2. **No confusion**: Developers know exactly where to look for mapping logic
|
||||
|
||||
3. **No wrapper services**: Domain mappers are functions, not services. No need for `InvoiceTransformerService` that just calls `transformWhmcsInvoice()`
|
||||
|
||||
4. **Simpler data flow**: Raw data → domain type. No intermediate representations.
|
||||
|
||||
### The Anti-Pattern: Multiple Transformations
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Multiple transformation layers
|
||||
const raw = await whmcsApi.getInvoice(id);
|
||||
const intermediate = customNormalize(raw); // First transform
|
||||
const domainType = transformWhmcsInvoice(intermediate); // Second transform
|
||||
const viewModel = mapToViewModel(domainType); // Third transform
|
||||
```
|
||||
|
||||
### The Pattern: Single Transformation
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Single transformation
|
||||
const raw = await whmcsApi.getInvoice(id);
|
||||
const invoice = transformWhmcsInvoice(raw); // One transform
|
||||
// Use invoice directly everywhere
|
||||
```
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
| ------------------------- | --------------------------- | -------------------------------- |
|
||||
| **Multiple layers** | "Separation of concerns" | Complexity, drift, hard to trace |
|
||||
| **Transformer services** | Injectable, testable | Unnecessary indirection |
|
||||
| **Single transformation** | Simple, traceable, no drift | Mappers must handle all cases |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Clear, traceable data flow
|
||||
- No wrapper services to maintain
|
||||
- One place for all transformation logic
|
||||
- Domain types used consistently everywhere
|
||||
|
||||
### Negative
|
||||
|
||||
- Domain mappers must handle all edge cases
|
||||
- Can't easily add "view-specific" transformations (but this is usually a sign of missing domain concepts)
|
||||
|
||||
## Implementation
|
||||
|
||||
### Integration Service Pattern
|
||||
|
||||
```typescript
|
||||
// apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts
|
||||
@Injectable()
|
||||
export class WhmcsInvoiceService {
|
||||
async getInvoice(id: number): Promise<Invoice> {
|
||||
// 1. Fetch raw data
|
||||
const raw = await this.whmcsClient.getInvoice({ invoiceid: id });
|
||||
|
||||
// 2. Transform ONCE with domain mapper
|
||||
return transformWhmcsInvoice(raw, {
|
||||
defaultCurrencyCode: this.currency.code,
|
||||
});
|
||||
|
||||
// 3. Return domain type - used directly everywhere
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Domain Mapper
|
||||
|
||||
```typescript
|
||||
// packages/domain/billing/providers/whmcs/mapper.ts
|
||||
export function transformWhmcsInvoice(raw: unknown, context: TransformContext): Invoice {
|
||||
// Validate input
|
||||
const validated = whmcsInvoiceRawSchema.parse(raw);
|
||||
|
||||
// Transform to domain model
|
||||
const result: Invoice = {
|
||||
id: validated.invoiceid,
|
||||
status: mapInvoiceStatus(validated.status),
|
||||
total: {
|
||||
amount: parseFloat(validated.total),
|
||||
currency: context.defaultCurrencyCode,
|
||||
},
|
||||
// ... all mappings in one place
|
||||
};
|
||||
|
||||
// Validate output
|
||||
return invoiceSchema.parse(result);
|
||||
}
|
||||
```
|
||||
|
||||
### What NOT to Do
|
||||
|
||||
```typescript
|
||||
// ❌ DON'T create wrapper services
|
||||
@Injectable()
|
||||
export class InvoiceTransformerService {
|
||||
transform(raw: WhmcsInvoice): Invoice {
|
||||
return transformWhmcsInvoice(raw); // Pointless wrapper
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ DON'T transform multiple times
|
||||
const raw = await api.fetch();
|
||||
const normalized = this.normalizer.normalize(raw);
|
||||
const mapped = this.mapper.map(normalized);
|
||||
const enriched = this.enricher.enrich(mapped);
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [BFF Integration Patterns](../development/bff/integration-patterns.md)
|
||||
- [Domain Structure](../development/domain/structure.md)
|
||||
- [ADR-004: Domain Provider Isolation](./004-domain-provider-isolation.md)
|
||||
141
docs/decisions/004-domain-provider-isolation.md
Normal file
141
docs/decisions/004-domain-provider-isolation.md
Normal file
@ -0,0 +1,141 @@
|
||||
# ADR-004: Domain Provider Isolation
|
||||
|
||||
**Date**: 2025-01-15
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The Customer Portal uses multiple external providers:
|
||||
|
||||
- **WHMCS** for billing (invoices, payment methods, subscriptions)
|
||||
- **Salesforce** for CRM (accounts, orders, cases, products)
|
||||
- **Freebit** for SIM management
|
||||
- **Japan Post** for address lookup
|
||||
|
||||
Each provider has its own API response structures. Application code should not need to know these details.
|
||||
|
||||
## Decision
|
||||
|
||||
**Isolate provider-specific code** in `providers/` subdirectories within each domain module. Only BFF integration code imports from providers; Portal and application code use only normalized domain types.
|
||||
|
||||
```
|
||||
packages/domain/billing/
|
||||
├── contract.ts # ✅ Import everywhere
|
||||
├── schema.ts # ✅ Import everywhere
|
||||
├── index.ts # ✅ Import everywhere
|
||||
└── providers/ # ⚠️ BFF-only imports
|
||||
└── whmcs/
|
||||
├── raw.types.ts # WHMCS-specific structures
|
||||
└── mapper.ts # WHMCS → domain transformation
|
||||
```
|
||||
|
||||
## Rationale
|
||||
|
||||
### Why Provider Isolation?
|
||||
|
||||
1. **Portal stays provider-agnostic**: Frontend code never knows if data came from WHMCS, Salesforce, or elsewhere
|
||||
|
||||
2. **Easy to add/swap providers**: Adding Stripe as a billing provider = add `providers/stripe/` folder. No changes to domain contract or application code.
|
||||
|
||||
3. **Clear import boundaries**: ESLint enforces that Portal cannot import from `providers/`
|
||||
|
||||
4. **Single responsibility**: Raw types and mappers live together, making it clear what's provider-specific
|
||||
|
||||
### Import Rules (ESLint Enforced)
|
||||
|
||||
```typescript
|
||||
// ✅ Allowed everywhere (Portal + BFF)
|
||||
import { Invoice, invoiceSchema } from "@customer-portal/domain/billing";
|
||||
|
||||
// ✅ Allowed in BFF only
|
||||
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers";
|
||||
|
||||
// ❌ Forbidden in Portal
|
||||
import { WhmcsInvoiceRaw } from "@customer-portal/domain/billing/providers/whmcs/raw.types";
|
||||
```
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
| -------------------------------------- | ------------------------------------------------ | -------------------------------------------------- |
|
||||
| **Provider types in separate package** | Clear separation | Multiple packages to maintain, harder to co-locate |
|
||||
| **Provider types in BFF** | BFF-only by default | Types not reusable, duplicated if needed elsewhere |
|
||||
| **Provider isolation in domain** | Co-located, clear boundaries, ESLint enforceable | Requires import discipline |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Portal remains provider-agnostic
|
||||
- Adding new providers is straightforward (new folder, no contract changes)
|
||||
- Clear, enforceable import boundaries
|
||||
- Co-located raw types + mappers
|
||||
|
||||
### Negative
|
||||
|
||||
- Requires ESLint rules to enforce boundaries
|
||||
- Developers must understand import restrictions
|
||||
|
||||
## Implementation
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
packages/domain/billing/
|
||||
├── contract.ts # Normalized types (Invoice, InvoiceStatus)
|
||||
├── schema.ts # Zod schemas (invoiceSchema)
|
||||
├── index.ts # Public exports
|
||||
└── providers/
|
||||
├── index.ts # Re-exports all provider mappers
|
||||
└── whmcs/
|
||||
├── raw.types.ts # WhmcsInvoiceRaw (API response shape)
|
||||
└── mapper.ts # transformWhmcsInvoice()
|
||||
```
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
```bash
|
||||
# 1. Create provider folder
|
||||
mkdir -p packages/domain/billing/providers/stripe
|
||||
|
||||
# 2. Add raw types
|
||||
# packages/domain/billing/providers/stripe/raw.types.ts
|
||||
export const stripeInvoiceRawSchema = z.object({ ... });
|
||||
export type StripeInvoiceRaw = z.infer<typeof stripeInvoiceRawSchema>;
|
||||
|
||||
# 3. Add mapper
|
||||
# packages/domain/billing/providers/stripe/mapper.ts
|
||||
export function transformStripeInvoice(raw: unknown): Invoice { ... }
|
||||
|
||||
# 4. Export from providers/index.ts
|
||||
export * from "./stripe/mapper.js";
|
||||
|
||||
# 5. Use in BFF integration service - no other changes needed!
|
||||
```
|
||||
|
||||
### ESLint Configuration
|
||||
|
||||
```javascript
|
||||
// eslint.config.mjs
|
||||
{
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ["@customer-portal/domain/*/providers/*"],
|
||||
message: "Portal cannot import provider-specific types. Use domain types instead.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Domain Structure](../development/domain/structure.md)
|
||||
- [Import Hygiene](../development/domain/import-hygiene.md)
|
||||
- [ADR-003: Map Once, Use Everywhere](./003-map-once-use-everywhere.md)
|
||||
153
docs/decisions/005-feature-module-pattern.md
Normal file
153
docs/decisions/005-feature-module-pattern.md
Normal file
@ -0,0 +1,153 @@
|
||||
# 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)
|
||||
169
docs/decisions/006-thin-controllers.md
Normal file
169
docs/decisions/006-thin-controllers.md
Normal file
@ -0,0 +1,169 @@
|
||||
# ADR-006: Thin Controllers
|
||||
|
||||
**Date**: 2025-01-15
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
NestJS controllers can contain varying amounts of logic:
|
||||
|
||||
- **Fat controllers**: Business logic, validation, transformation all in controller
|
||||
- **Thin controllers**: HTTP handling only, delegate everything else
|
||||
|
||||
## Decision
|
||||
|
||||
**Controllers handle HTTP concerns only**. All business logic lives in services.
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Thin controller
|
||||
@Get()
|
||||
async getInvoices(
|
||||
@Request() req: RequestWithUser,
|
||||
@Query() query: InvoiceListQueryDto
|
||||
): Promise<InvoiceList> {
|
||||
return this.invoiceService.getInvoices(req.user.id, query);
|
||||
}
|
||||
|
||||
// ❌ BAD: Fat controller with business logic
|
||||
@Get()
|
||||
async getInvoices(@Request() req, @Query() query) {
|
||||
const clientId = await this.mappings.getClientId(req.user.id);
|
||||
const raw = await this.whmcs.getInvoices({ clientId });
|
||||
const filtered = raw.filter(inv => inv.status !== 'draft');
|
||||
return filtered.map(inv => transformInvoice(inv));
|
||||
}
|
||||
```
|
||||
|
||||
## Rationale
|
||||
|
||||
### Why Thin Controllers?
|
||||
|
||||
1. **Single responsibility**: Controllers handle HTTP (request parsing, response formatting, status codes). Services handle business logic.
|
||||
|
||||
2. **Testability**: Business logic in services can be unit tested without HTTP mocking.
|
||||
|
||||
3. **Reusability**: Service methods can be called from:
|
||||
- Multiple controllers
|
||||
- Background jobs
|
||||
- Event handlers
|
||||
- CLI commands
|
||||
|
||||
4. **Consistency**: Developers know controllers are just HTTP glue.
|
||||
|
||||
### Controller Responsibilities
|
||||
|
||||
**DO in controllers:**
|
||||
|
||||
- Route definition (`@Get()`, `@Post()`, etc.)
|
||||
- Request parsing (`@Body()`, `@Query()`, `@Param()`)
|
||||
- Authentication extraction (`@Request() req`)
|
||||
- HTTP status codes (`@HttpCode()`)
|
||||
- OpenAPI documentation (`@ZodResponse()`)
|
||||
- Call ONE service method
|
||||
|
||||
**DON'T in controllers:**
|
||||
|
||||
- Business logic
|
||||
- Data transformation
|
||||
- External API calls
|
||||
- Database queries
|
||||
- Multiple service orchestration
|
||||
- Error handling beyond HTTP concerns
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
| -------------------- | ----------------------- | ------------------------ |
|
||||
| **Fat controllers** | Everything in one place | Untestable, not reusable |
|
||||
| **Thin controllers** | Testable, reusable, SRP | More files |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Clear separation of concerns
|
||||
- Testable business logic
|
||||
- Reusable service methods
|
||||
- Consistent patterns
|
||||
|
||||
### Negative
|
||||
|
||||
- More files (controller + service)
|
||||
- Developers might create pass-through services
|
||||
|
||||
## Implementation
|
||||
|
||||
### Controller Structure
|
||||
|
||||
```typescript
|
||||
// apps/bff/src/modules/billing/billing.controller.ts
|
||||
@Controller("invoices")
|
||||
export class BillingController {
|
||||
constructor(private readonly invoiceService: InvoiceRetrievalService) {}
|
||||
|
||||
@Get()
|
||||
@ZodResponse({ description: "List invoices", type: InvoiceListDto })
|
||||
async getInvoices(
|
||||
@Request() req: RequestWithUser,
|
||||
@Query() query: InvoiceListQueryDto
|
||||
): Promise<InvoiceList> {
|
||||
// One service call - that's it
|
||||
return this.invoiceService.getInvoices(req.user.id, query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ZodResponse({ description: "Get invoice", type: InvoiceDto })
|
||||
async getInvoice(@Request() req: RequestWithUser, @Param("id") id: string): Promise<Invoice> {
|
||||
return this.invoiceService.getInvoice(req.user.id, parseInt(id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service Structure
|
||||
|
||||
```typescript
|
||||
// apps/bff/src/modules/billing/services/invoice-retrieval.service.ts
|
||||
@Injectable()
|
||||
export class InvoiceRetrievalService {
|
||||
constructor(
|
||||
private readonly whmcsInvoice: WhmcsInvoiceService,
|
||||
private readonly mappings: MappingsService
|
||||
) {}
|
||||
|
||||
async getInvoices(userId: string, query: InvoiceQuery): Promise<InvoiceList> {
|
||||
// Business logic here
|
||||
const clientId = await this.mappings.getWhmcsClientId(userId);
|
||||
|
||||
const invoices = await this.whmcsInvoice.getInvoices(clientId, {
|
||||
status: query.status,
|
||||
limit: query.limit,
|
||||
});
|
||||
|
||||
return {
|
||||
items: invoices,
|
||||
total: invoices.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### What NOT to Do
|
||||
|
||||
```typescript
|
||||
// ❌ DON'T: Business logic in controller
|
||||
@Get()
|
||||
async getInvoices(@Request() req, @Query() query) {
|
||||
const clientId = await this.mappings.getClientId(req.user.id);
|
||||
const raw = await this.whmcs.api.getInvoices({ clientId });
|
||||
|
||||
// Filtering, transformation, validation - all wrong here
|
||||
return raw.invoices
|
||||
.filter(inv => query.status ? inv.status === query.status : true)
|
||||
.map(inv => transformWhmcsInvoice(inv));
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [BFF Integration Patterns](../development/bff/integration-patterns.md)
|
||||
- [ADR-005: Feature Module Pattern](./005-feature-module-pattern.md)
|
||||
68
docs/decisions/README.md
Normal file
68
docs/decisions/README.md
Normal file
@ -0,0 +1,68 @@
|
||||
# Architecture Decision Records (ADRs)
|
||||
|
||||
This folder documents key architectural decisions made in the Customer Portal project. Each ADR explains **what** was decided, **why** it was decided, and **what alternatives** were considered.
|
||||
|
||||
---
|
||||
|
||||
## Why ADRs?
|
||||
|
||||
ADRs help AI tools and new developers understand:
|
||||
|
||||
- The reasoning behind non-obvious design choices
|
||||
- Why certain patterns are used consistently
|
||||
- What alternatives were rejected and why
|
||||
|
||||
---
|
||||
|
||||
## Decision Index
|
||||
|
||||
| ADR | Title | Status | Summary |
|
||||
| --------------------------------------------- | ----------------------------- | -------- | --------------------------------------------------------------------------- |
|
||||
| [001](./001-platform-events-over-webhooks.md) | Platform Events over Webhooks | Accepted | Use Salesforce Platform Events instead of inbound webhooks for provisioning |
|
||||
| [002](./002-zod-first-validation.md) | Zod-First Validation | Accepted | Zod schemas as single source of truth for validation |
|
||||
| [003](./003-map-once-use-everywhere.md) | Map Once, Use Everywhere | Accepted | Single transformation point in domain mappers |
|
||||
| [004](./004-domain-provider-isolation.md) | Domain Provider Isolation | Accepted | Provider-specific code isolated in domain/providers/ |
|
||||
| [005](./005-feature-module-pattern.md) | Feature Module Pattern | Accepted | Portal organized by feature modules with consistent structure |
|
||||
| [006](./006-thin-controllers.md) | Thin Controllers | Accepted | Controllers handle HTTP only, delegate to services |
|
||||
|
||||
---
|
||||
|
||||
## ADR Template
|
||||
|
||||
When adding new ADRs, use this template:
|
||||
|
||||
```markdown
|
||||
# ADR-XXX: Title
|
||||
|
||||
**Date**: YYYY-MM-DD
|
||||
**Status**: Proposed | Accepted | Deprecated | Superseded
|
||||
|
||||
## Context
|
||||
|
||||
What is the issue or problem we're trying to solve?
|
||||
|
||||
## Decision
|
||||
|
||||
What is the change that we're proposing and/or doing?
|
||||
|
||||
## Rationale
|
||||
|
||||
Why is this the best approach? What alternatives were considered?
|
||||
|
||||
## Consequences
|
||||
|
||||
What are the positive and negative outcomes of this decision?
|
||||
|
||||
## Related
|
||||
|
||||
- Links to related docs, code, or other ADRs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [System Overview](../architecture/system-overview.md)
|
||||
- [Domain Structure](../development/domain/structure.md)
|
||||
- [BFF Integration Patterns](../development/bff/integration-patterns.md)
|
||||
- [Portal Architecture](../development/portal/architecture.md)
|
||||
Loading…
x
Reference in New Issue
Block a user