From 26502b43561fa843504323c9efe5a1466db306d2 Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 15 Jan 2026 16:31:48 +0900 Subject: [PATCH] feat: add architecture decision records for key design choices --- .../001-platform-events-over-webhooks.md | 80 +++++++++ docs/decisions/002-zod-first-validation.md | 129 +++++++++++++ docs/decisions/003-map-once-use-everywhere.md | 147 +++++++++++++++ .../004-domain-provider-isolation.md | 141 +++++++++++++++ docs/decisions/005-feature-module-pattern.md | 153 ++++++++++++++++ docs/decisions/006-thin-controllers.md | 169 ++++++++++++++++++ docs/decisions/README.md | 68 +++++++ 7 files changed, 887 insertions(+) create mode 100644 docs/decisions/001-platform-events-over-webhooks.md create mode 100644 docs/decisions/002-zod-first-validation.md create mode 100644 docs/decisions/003-map-once-use-everywhere.md create mode 100644 docs/decisions/004-domain-provider-isolation.md create mode 100644 docs/decisions/005-feature-module-pattern.md create mode 100644 docs/decisions/006-thin-controllers.md create mode 100644 docs/decisions/README.md diff --git a/docs/decisions/001-platform-events-over-webhooks.md b/docs/decisions/001-platform-events-over-webhooks.md new file mode 100644 index 00000000..14acacb8 --- /dev/null +++ b/docs/decisions/001-platform-events-over-webhooks.md @@ -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) diff --git a/docs/decisions/002-zod-first-validation.md b/docs/decisions/002-zod-first-validation.md new file mode 100644 index 00000000..551e0e9a --- /dev/null +++ b/docs/decisions/002-zod-first-validation.md @@ -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; +``` + +## 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) diff --git a/docs/decisions/003-map-once-use-everywhere.md b/docs/decisions/003-map-once-use-everywhere.md new file mode 100644 index 00000000..8910e0b4 --- /dev/null +++ b/docs/decisions/003-map-once-use-everywhere.md @@ -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 { + // 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) diff --git a/docs/decisions/004-domain-provider-isolation.md b/docs/decisions/004-domain-provider-isolation.md new file mode 100644 index 00000000..8d2e5025 --- /dev/null +++ b/docs/decisions/004-domain-provider-isolation.md @@ -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; + +# 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) diff --git a/docs/decisions/005-feature-module-pattern.md b/docs/decisions/005-feature-module-pattern.md new file mode 100644 index 00000000..59aa330a --- /dev/null +++ b/docs/decisions/005-feature-module-pattern.md @@ -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 ; +} + +// ❌ BAD: Page contains logic +export default function InvoicesPage() { + const invoices = useInvoices(); // Data fetching in page + return ; // 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 ; +} + +// 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 ; +} +``` + +## Related + +- [Portal Architecture](../development/portal/architecture.md) +- [ADR-006: Thin Controllers](./006-thin-controllers.md) diff --git a/docs/decisions/006-thin-controllers.md b/docs/decisions/006-thin-controllers.md new file mode 100644 index 00000000..0fdc86f0 --- /dev/null +++ b/docs/decisions/006-thin-controllers.md @@ -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 { + 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 { + // 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 { + 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 { + // 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) diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 00000000..c4e159bb --- /dev/null +++ b/docs/decisions/README.md @@ -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)