feat: add architecture decision records for key design choices

This commit is contained in:
barsa 2026-01-15 16:31:48 +09:00
parent 37ef51cc82
commit 26502b4356
7 changed files with 887 additions and 0 deletions

View 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)

View 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)

View 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)

View 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)

View 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)

View 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
View 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)