@garethdaine/agentops 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +10 -0
- package/LICENSE +21 -0
- package/README.md +410 -0
- package/agents/architecture-researcher.md +115 -0
- package/agents/code-critic.md +190 -0
- package/agents/delegation-router.md +40 -0
- package/agents/feature-researcher.md +117 -0
- package/agents/interrogator.md +11 -0
- package/agents/pitfalls-researcher.md +112 -0
- package/agents/plan-validator.md +173 -0
- package/agents/proposer.md +61 -0
- package/agents/security-reviewer.md +189 -0
- package/agents/skill-builder.md +43 -0
- package/agents/spec-compliance-reviewer.md +154 -0
- package/agents/stack-researcher.md +89 -0
- package/commands/build.md +766 -0
- package/commands/code-analysis.md +39 -0
- package/commands/code-field.md +22 -0
- package/commands/compliance-check.md +34 -0
- package/commands/configure.md +178 -0
- package/commands/cost-report.md +17 -0
- package/commands/enterprise/adr.md +78 -0
- package/commands/enterprise/brainstorm.md +461 -0
- package/commands/enterprise/design.md +203 -0
- package/commands/enterprise/dev-setup.md +136 -0
- package/commands/enterprise/docker-dev.md +229 -0
- package/commands/enterprise/e2e.md +233 -0
- package/commands/enterprise/feature.md +218 -0
- package/commands/enterprise/gap-analysis.md +204 -0
- package/commands/enterprise/handover.md +195 -0
- package/commands/enterprise/herd.md +152 -0
- package/commands/enterprise/knowledge.md +173 -0
- package/commands/enterprise/onboard.md +86 -0
- package/commands/enterprise/qa-check.md +80 -0
- package/commands/enterprise/reason.md +196 -0
- package/commands/enterprise/review.md +177 -0
- package/commands/enterprise/scaffold.md +153 -0
- package/commands/enterprise/status-report.md +101 -0
- package/commands/enterprise/tech-catalog.md +170 -0
- package/commands/enterprise/test-gen.md +138 -0
- package/commands/evolve.md +39 -0
- package/commands/flags.md +44 -0
- package/commands/interrogate.md +263 -0
- package/commands/lesson.md +15 -0
- package/commands/lessons.md +10 -0
- package/commands/plan.md +44 -0
- package/commands/prune.md +27 -0
- package/commands/star.md +17 -0
- package/commands/supply-chain-scan.md +44 -0
- package/commands/unicode-scan.md +63 -0
- package/commands/verify.md +41 -0
- package/commands/workflow.md +436 -0
- package/hooks/ai-guardrails.sh +114 -0
- package/hooks/audit-log.sh +26 -0
- package/hooks/auto-delegate.sh +45 -0
- package/hooks/auto-evolve.sh +22 -0
- package/hooks/auto-lesson.sh +26 -0
- package/hooks/auto-plan.sh +59 -0
- package/hooks/auto-test.sh +46 -0
- package/hooks/auto-verify.sh +30 -0
- package/hooks/budget-check.sh +24 -0
- package/hooks/code-field-preamble.sh +30 -0
- package/hooks/compliance-gate.sh +50 -0
- package/hooks/content-trust.sh +22 -0
- package/hooks/credential-redact.sh +23 -0
- package/hooks/delegation-trust.sh +15 -0
- package/hooks/detect-test-run.sh +19 -0
- package/hooks/enforcement-lib.sh +60 -0
- package/hooks/evolve-gate.sh +32 -0
- package/hooks/evolve-lib.sh +32 -0
- package/hooks/exfiltration-check.sh +67 -0
- package/hooks/failure-collector.sh +27 -0
- package/hooks/feature-flags.sh +67 -0
- package/hooks/file-provenance.sh +31 -0
- package/hooks/flag-utils.sh +36 -0
- package/hooks/hooks.json +145 -0
- package/hooks/injection-scan.sh +58 -0
- package/hooks/integrity-verify.sh +91 -0
- package/hooks/lessons-check.sh +17 -0
- package/hooks/lockfile-audit.sh +109 -0
- package/hooks/patterns-lib.sh +22 -0
- package/hooks/plan-gate.sh +18 -0
- package/hooks/redact-lib.sh +15 -0
- package/hooks/runtime-mode.sh +56 -0
- package/hooks/session-cleanup.sh +74 -0
- package/hooks/skill-validator.sh +28 -0
- package/hooks/standards-enforce.sh +106 -0
- package/hooks/star-gate.sh +93 -0
- package/hooks/star-preamble.sh +10 -0
- package/hooks/telemetry.sh +33 -0
- package/hooks/todo-prune.sh +84 -0
- package/hooks/unicode-firewall.sh +122 -0
- package/hooks/unicode-lib.sh +66 -0
- package/hooks/unicode-scan-session.sh +96 -0
- package/hooks/validate-command.sh +103 -0
- package/hooks/validate-env.sh +51 -0
- package/hooks/validate-path.sh +81 -0
- package/package.json +40 -0
- package/settings.json +6 -0
- package/templates/ai-config/tool-standards.md +56 -0
- package/templates/architecture/api-first.md +192 -0
- package/templates/architecture/auth-patterns.md +302 -0
- package/templates/architecture/caching-strategy.md +359 -0
- package/templates/architecture/database-patterns.md +347 -0
- package/templates/architecture/event-driven.md +252 -0
- package/templates/architecture/integration-patterns.md +185 -0
- package/templates/architecture/multi-tenancy.md +104 -0
- package/templates/architecture/service-boundaries.md +200 -0
- package/templates/build/brief-template.md +86 -0
- package/templates/build/summary-template.md +100 -0
- package/templates/build/task-plan-template.md +133 -0
- package/templates/communication/effort-estimate.md +54 -0
- package/templates/communication/incident-response.md +59 -0
- package/templates/communication/post-mortem.md +109 -0
- package/templates/communication/risk-register.md +43 -0
- package/templates/communication/sprint-demo-checklist.md +64 -0
- package/templates/communication/stakeholder-presentation-outline.md +84 -0
- package/templates/communication/technical-proposal.md +77 -0
- package/templates/delivery/deployment/deployment-checklist.md +49 -0
- package/templates/delivery/design/solution-design-checklist.md +37 -0
- package/templates/delivery/discovery/stakeholder-questions.md +33 -0
- package/templates/delivery/handover/knowledge-transfer-checklist.md +75 -0
- package/templates/delivery/handover/operational-runbook.md +117 -0
- package/templates/delivery/handover/support-escalation-matrix.md +56 -0
- package/templates/delivery/implementation/blocker-escalation-template.md +55 -0
- package/templates/delivery/implementation/sprint-planning-template.md +49 -0
- package/templates/delivery/implementation/task-decomposition-guide.md +59 -0
- package/templates/delivery/qa/test-plan-template.md +76 -0
- package/templates/delivery/qa/test-results-template.md +55 -0
- package/templates/delivery/qa/uat-signoff-template.md +44 -0
- package/templates/governance/codeowners.md +60 -0
- package/templates/integration/adapter-pattern.md +160 -0
- package/templates/scaffolds/env-validation.md +85 -0
- package/templates/scaffolds/error-handling.md +171 -0
- package/templates/scaffolds/graceful-shutdown.md +139 -0
- package/templates/scaffolds/health-check.md +109 -0
- package/templates/scaffolds/structured-logging.md +134 -0
- package/templates/standards/engineering-standards.md +413 -0
- package/templates/standards/standards-checklist.md +125 -0
- package/templates/tech-catalog.json +663 -0
- package/templates/utilities/project-detection.md +75 -0
- package/templates/utilities/requirements-collection.md +68 -0
- package/templates/utilities/template-rendering.md +81 -0
- package/templates/workflows/architecture-decision.md +90 -0
- package/templates/workflows/bug-investigation.md +83 -0
- package/templates/workflows/feature-implementation.md +80 -0
- package/templates/workflows/refactoring.md +83 -0
- package/templates/workflows/spike-exploration.md +82 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# Architecture Pattern: Event-Driven Architecture
|
|
2
|
+
|
|
3
|
+
## When to Use
|
|
4
|
+
|
|
5
|
+
- Decoupling producers from consumers so they can evolve independently
|
|
6
|
+
- Processing that can tolerate eventual consistency (order fulfilment, notifications, analytics)
|
|
7
|
+
- Fan-out scenarios where one action triggers multiple downstream reactions
|
|
8
|
+
- Replacing synchronous chains that create fragile coupling between services
|
|
9
|
+
|
|
10
|
+
## Pattern Description
|
|
11
|
+
|
|
12
|
+
Event-driven architecture uses events as the primary communication mechanism between components. A producer emits an event describing something that happened. One or more consumers react to that event independently. The producer does not know (or care) who consumes the event or what they do with it.
|
|
13
|
+
|
|
14
|
+
## Event Schema Design (CloudEvents)
|
|
15
|
+
|
|
16
|
+
Use a standardised envelope to ensure interoperability across services:
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
/**
|
|
20
|
+
* CloudEvents-compliant event envelope.
|
|
21
|
+
* See https://cloudevents.io for the full specification.
|
|
22
|
+
*/
|
|
23
|
+
export interface DomainEvent<T = unknown> {
|
|
24
|
+
/** Unique event identifier */
|
|
25
|
+
id: string;
|
|
26
|
+
/** Reverse-DNS event type, e.g. 'com.acme.order.created' */
|
|
27
|
+
type: string;
|
|
28
|
+
/** Event origin, e.g. 'orders-service' */
|
|
29
|
+
source: string;
|
|
30
|
+
/** CloudEvents spec version */
|
|
31
|
+
specversion: '1.0';
|
|
32
|
+
/** ISO 8601 timestamp */
|
|
33
|
+
time: string;
|
|
34
|
+
/** Content type of the data payload */
|
|
35
|
+
datacontenttype: 'application/json';
|
|
36
|
+
/** The event payload */
|
|
37
|
+
data: T;
|
|
38
|
+
/** Optional: subject the event relates to */
|
|
39
|
+
subject?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Factory function for consistent event creation
|
|
43
|
+
export function createEvent<T>(
|
|
44
|
+
type: string,
|
|
45
|
+
source: string,
|
|
46
|
+
data: T,
|
|
47
|
+
subject?: string,
|
|
48
|
+
): DomainEvent<T> {
|
|
49
|
+
return {
|
|
50
|
+
id: crypto.randomUUID(),
|
|
51
|
+
type,
|
|
52
|
+
source,
|
|
53
|
+
specversion: '1.0',
|
|
54
|
+
time: new Date().toISOString(),
|
|
55
|
+
datacontenttype: 'application/json',
|
|
56
|
+
data,
|
|
57
|
+
subject,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Event Bus Abstraction
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
export interface EventBus {
|
|
66
|
+
publish(event: DomainEvent): Promise<void>;
|
|
67
|
+
subscribe(eventType: string, handler: EventHandler): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type EventHandler<T = unknown> = (event: DomainEvent<T>) => Promise<void>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* In-memory implementation for development and testing.
|
|
74
|
+
* Swap for SQS/EventBridge/Redis Streams in production.
|
|
75
|
+
*/
|
|
76
|
+
export class InMemoryEventBus implements EventBus {
|
|
77
|
+
private handlers = new Map<string, EventHandler[]>();
|
|
78
|
+
|
|
79
|
+
async publish(event: DomainEvent): Promise<void> {
|
|
80
|
+
const subscribers = this.handlers.get(event.type) ?? [];
|
|
81
|
+
await Promise.allSettled(subscribers.map((h) => h(event)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
subscribe(eventType: string, handler: EventHandler): void {
|
|
85
|
+
const existing = this.handlers.get(eventType) ?? [];
|
|
86
|
+
this.handlers.set(eventType, [...existing, handler]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Idempotency
|
|
92
|
+
|
|
93
|
+
Consumers must handle receiving the same event more than once. At-least-once delivery is the norm for most message systems.
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
export class IdempotentHandler<T> {
|
|
97
|
+
constructor(
|
|
98
|
+
private store: IdempotencyStore,
|
|
99
|
+
private handler: EventHandler<T>,
|
|
100
|
+
) {}
|
|
101
|
+
|
|
102
|
+
async handle(event: DomainEvent<T>): Promise<void> {
|
|
103
|
+
const alreadyProcessed = await this.store.exists(event.id);
|
|
104
|
+
if (alreadyProcessed) {
|
|
105
|
+
logger.info('Skipping duplicate event', { eventId: event.id });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await this.handler(event);
|
|
110
|
+
await this.store.markProcessed(event.id, { ttlSeconds: 86400 * 7 });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface IdempotencyStore {
|
|
115
|
+
exists(eventId: string): Promise<boolean>;
|
|
116
|
+
markProcessed(eventId: string, options?: { ttlSeconds: number }): Promise<void>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Redis-backed implementation
|
|
120
|
+
export class RedisIdempotencyStore implements IdempotencyStore {
|
|
121
|
+
constructor(private redis: RedisClient) {}
|
|
122
|
+
|
|
123
|
+
async exists(eventId: string): Promise<boolean> {
|
|
124
|
+
const result = await this.redis.get(`idempotency:${eventId}`);
|
|
125
|
+
return result !== null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async markProcessed(eventId: string, options?: { ttlSeconds: number }): Promise<void> {
|
|
129
|
+
await this.redis.set(`idempotency:${eventId}`, '1', { EX: options?.ttlSeconds ?? 604800 });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Dead Letter Queue Handling
|
|
135
|
+
|
|
136
|
+
When a consumer fails repeatedly, move the event to a dead letter queue for manual inspection:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
export class RetryableConsumer<T> {
|
|
140
|
+
constructor(
|
|
141
|
+
private handler: EventHandler<T>,
|
|
142
|
+
private dlq: DeadLetterQueue,
|
|
143
|
+
private maxRetries: number = 3,
|
|
144
|
+
) {}
|
|
145
|
+
|
|
146
|
+
async process(event: DomainEvent<T>, attemptCount: number): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
await this.handler(event);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (attemptCount >= this.maxRetries) {
|
|
151
|
+
logger.error('Max retries exceeded, sending to DLQ', {
|
|
152
|
+
eventId: event.id,
|
|
153
|
+
eventType: event.type,
|
|
154
|
+
error: error instanceof Error ? error.message : String(error),
|
|
155
|
+
});
|
|
156
|
+
await this.dlq.send(event, {
|
|
157
|
+
error: error instanceof Error ? error.message : String(error),
|
|
158
|
+
attempts: attemptCount,
|
|
159
|
+
failedAt: new Date().toISOString(),
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
throw error; // Let the queue infrastructure retry
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface DeadLetterQueue {
|
|
169
|
+
send(event: DomainEvent, metadata: Record<string, unknown>): Promise<void>;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Event Ordering Guarantees
|
|
174
|
+
|
|
175
|
+
Most message systems guarantee ordering only within a partition or message group:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
/**
|
|
179
|
+
* Use a consistent partition key so events for the same entity
|
|
180
|
+
* are processed in order.
|
|
181
|
+
*
|
|
182
|
+
* SQS FIFO: MessageGroupId
|
|
183
|
+
* Kafka: partition key
|
|
184
|
+
* Redis Streams: stream key
|
|
185
|
+
*/
|
|
186
|
+
export function publishOrderEvent(event: DomainEvent<OrderData>): Promise<void> {
|
|
187
|
+
return queue.send({
|
|
188
|
+
body: JSON.stringify(event),
|
|
189
|
+
// All events for the same order go to the same partition
|
|
190
|
+
messageGroupId: event.data.orderId,
|
|
191
|
+
// Deduplication ID for exactly-once in FIFO queues
|
|
192
|
+
messageDeduplicationId: event.id,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Saga / Choreography Pattern
|
|
198
|
+
|
|
199
|
+
Coordinate multi-step workflows through event chains rather than a central orchestrator:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
/**
|
|
203
|
+
* Example: Order fulfilment saga via choreography
|
|
204
|
+
*
|
|
205
|
+
* 1. OrderService emits 'order.placed'
|
|
206
|
+
* 2. PaymentService listens, charges card, emits 'payment.captured'
|
|
207
|
+
* 3. InventoryService listens, reserves stock, emits 'inventory.reserved'
|
|
208
|
+
* 4. ShippingService listens, creates shipment, emits 'shipment.created'
|
|
209
|
+
*
|
|
210
|
+
* Compensating actions if any step fails:
|
|
211
|
+
* - 'payment.failed' -> OrderService cancels the order
|
|
212
|
+
* - 'inventory.insufficient' -> PaymentService refunds, OrderService cancels
|
|
213
|
+
*/
|
|
214
|
+
|
|
215
|
+
// Payment service consumer
|
|
216
|
+
eventBus.subscribe('order.placed', async (event: DomainEvent<OrderPlaced>) => {
|
|
217
|
+
try {
|
|
218
|
+
const payment = await paymentService.charge(event.data.paymentMethodId, event.data.total);
|
|
219
|
+
await eventBus.publish(createEvent('payment.captured', 'payment-service', {
|
|
220
|
+
orderId: event.data.orderId,
|
|
221
|
+
paymentId: payment.id,
|
|
222
|
+
}));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
await eventBus.publish(createEvent('payment.failed', 'payment-service', {
|
|
225
|
+
orderId: event.data.orderId,
|
|
226
|
+
reason: error instanceof Error ? error.message : 'Unknown error',
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Order service listens for compensation
|
|
232
|
+
eventBus.subscribe('payment.failed', async (event: DomainEvent<PaymentFailed>) => {
|
|
233
|
+
await orderService.cancel(event.data.orderId, event.data.reason);
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Trade-offs
|
|
238
|
+
|
|
239
|
+
- **Eventual consistency:** Consumers process events asynchronously. The system is not immediately consistent after a write.
|
|
240
|
+
- **Debugging difficulty:** Tracing a request across event chains requires correlation IDs and distributed tracing.
|
|
241
|
+
- **Ordering complexity:** Global ordering is expensive. Design for partition-level ordering or tolerate out-of-order delivery.
|
|
242
|
+
- **Schema evolution:** Changing event shapes requires versioning and backward-compatible consumers.
|
|
243
|
+
- **Operational overhead:** Dead letter queues, retry policies, and monitoring infrastructure must be maintained.
|
|
244
|
+
|
|
245
|
+
## Common Pitfalls
|
|
246
|
+
|
|
247
|
+
1. **Fat events with too much data** — Include only what consumers need. Large payloads increase coupling and bandwidth costs.
|
|
248
|
+
2. **Missing idempotency** — At-least-once delivery means duplicates will happen. Every consumer must handle them.
|
|
249
|
+
3. **Synchronous event processing disguised as async** — Publishing an event and then polling for the result defeats the purpose.
|
|
250
|
+
4. **No dead letter queue** — Failed events that disappear silently cause data loss.
|
|
251
|
+
5. **Tight schema coupling** — Consumers that break when a new field is added to an event are too tightly coupled. Use tolerant readers.
|
|
252
|
+
6. **Ignoring backpressure** — A fast producer can overwhelm a slow consumer. Use queue depth monitoring and rate limiting.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Architecture Pattern: Integration Patterns
|
|
2
|
+
|
|
3
|
+
## Adapter Pattern
|
|
4
|
+
|
|
5
|
+
The primary pattern for integrating with external systems. Protects your domain from external API changes.
|
|
6
|
+
|
|
7
|
+
### Interface Definition
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
/**
|
|
11
|
+
* Define a typed interface for every external system integration.
|
|
12
|
+
* This is the contract your domain depends on — NOT the external API shape.
|
|
13
|
+
*/
|
|
14
|
+
export interface ProcurementAdapter {
|
|
15
|
+
getOrders(tenantId: string, filters?: OrderFilters): Promise<PaginatedResult<Order>>;
|
|
16
|
+
getOrderById(tenantId: string, orderId: string): Promise<Order | null>;
|
|
17
|
+
createOrder(tenantId: string, data: CreateOrderInput): Promise<Order>;
|
|
18
|
+
updateOrderStatus(tenantId: string, orderId: string, status: OrderStatus): Promise<Order>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OrderFilters {
|
|
22
|
+
status?: OrderStatus;
|
|
23
|
+
dateFrom?: Date;
|
|
24
|
+
dateTo?: Date;
|
|
25
|
+
limit?: number;
|
|
26
|
+
offset?: number;
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Mock Implementation
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
/**
|
|
34
|
+
* Mock adapter for development and testing.
|
|
35
|
+
* Returns realistic data without hitting external APIs.
|
|
36
|
+
*/
|
|
37
|
+
export class MockProcurementAdapter implements ProcurementAdapter {
|
|
38
|
+
private orders: Map<string, Order[]> = new Map();
|
|
39
|
+
|
|
40
|
+
async getOrders(tenantId: string, filters?: OrderFilters): Promise<PaginatedResult<Order>> {
|
|
41
|
+
const tenantOrders = this.orders.get(tenantId) ?? this.seedData(tenantId);
|
|
42
|
+
// Apply filters, pagination...
|
|
43
|
+
return { data: tenantOrders, total: tenantOrders.length, limit: 20, offset: 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ... implement all interface methods with in-memory data
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Real Implementation
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
/**
|
|
54
|
+
* Real adapter calling the external procurement API.
|
|
55
|
+
* Handles auth, retries, error mapping, and response transformation.
|
|
56
|
+
*/
|
|
57
|
+
export class HttpProcurementAdapter implements ProcurementAdapter {
|
|
58
|
+
constructor(
|
|
59
|
+
private baseUrl: string,
|
|
60
|
+
private apiKey: string,
|
|
61
|
+
private httpClient: HttpClient,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
async getOrders(tenantId: string, filters?: OrderFilters): Promise<PaginatedResult<Order>> {
|
|
65
|
+
const response = await this.httpClient.get(`${this.baseUrl}/orders`, {
|
|
66
|
+
headers: { 'X-Tenant-ID': tenantId, Authorization: `Bearer ${this.apiKey}` },
|
|
67
|
+
params: this.mapFilters(filters),
|
|
68
|
+
});
|
|
69
|
+
return this.mapResponse(response.data);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Map external API response to domain model */
|
|
73
|
+
private mapResponse(external: ExternalOrderResponse): PaginatedResult<Order> {
|
|
74
|
+
return {
|
|
75
|
+
data: external.items.map(this.mapOrder),
|
|
76
|
+
total: external.totalCount,
|
|
77
|
+
limit: external.pageSize,
|
|
78
|
+
offset: external.page * external.pageSize,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Factory / DI Registration
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
export function createProcurementAdapter(): ProcurementAdapter {
|
|
88
|
+
if (env.NODE_ENV === 'test' || env.USE_MOCK_ADAPTERS === 'true') {
|
|
89
|
+
return new MockProcurementAdapter();
|
|
90
|
+
}
|
|
91
|
+
return new HttpProcurementAdapter(env.PROCUREMENT_API_URL, env.PROCUREMENT_API_KEY, httpClient);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Anti-Corruption Layer
|
|
96
|
+
|
|
97
|
+
Prevent external API models from leaking into your domain:
|
|
98
|
+
- Define your own domain types (never use external response types directly)
|
|
99
|
+
- Map at the adapter boundary
|
|
100
|
+
- Validate external responses before mapping
|
|
101
|
+
- Handle missing/unexpected fields gracefully
|
|
102
|
+
|
|
103
|
+
## Circuit Breaker Pattern
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
class CircuitBreaker {
|
|
107
|
+
private failures = 0;
|
|
108
|
+
private lastFailure?: Date;
|
|
109
|
+
private state: 'closed' | 'open' | 'half-open' = 'closed';
|
|
110
|
+
|
|
111
|
+
constructor(
|
|
112
|
+
private threshold: number = 5,
|
|
113
|
+
private resetTimeoutMs: number = 30_000,
|
|
114
|
+
) {}
|
|
115
|
+
|
|
116
|
+
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
|
117
|
+
if (this.state === 'open') {
|
|
118
|
+
if (Date.now() - this.lastFailure!.getTime() > this.resetTimeoutMs) {
|
|
119
|
+
this.state = 'half-open';
|
|
120
|
+
} else {
|
|
121
|
+
throw new AppError('Service unavailable (circuit open)', { statusCode: 503 });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const result = await fn();
|
|
126
|
+
this.onSuccess();
|
|
127
|
+
return result;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.onFailure();
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Retry Policy
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
async function withRetry<T>(
|
|
140
|
+
fn: () => Promise<T>,
|
|
141
|
+
options: { maxRetries: number; backoffMs: number; retryableErrors?: string[] },
|
|
142
|
+
): Promise<T> {
|
|
143
|
+
for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
|
|
144
|
+
try {
|
|
145
|
+
return await fn();
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (attempt === options.maxRetries) throw error;
|
|
148
|
+
const isRetryable = error instanceof AppError && error.statusCode >= 500;
|
|
149
|
+
if (!isRetryable) throw error;
|
|
150
|
+
await sleep(options.backoffMs * Math.pow(2, attempt));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
throw new Error('Unreachable');
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Contract Testing
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
/**
|
|
161
|
+
* Contract tests verify that both mock and real adapters behave identically.
|
|
162
|
+
* Run against mock in CI, against real in integration environment.
|
|
163
|
+
*/
|
|
164
|
+
describe('ProcurementAdapter contract', () => {
|
|
165
|
+
const adapters = [
|
|
166
|
+
['mock', new MockProcurementAdapter()],
|
|
167
|
+
// ['real', new HttpProcurementAdapter(...)], // Enable in integration env
|
|
168
|
+
] as const;
|
|
169
|
+
|
|
170
|
+
adapters.forEach(([name, adapter]) => {
|
|
171
|
+
describe(name, () => {
|
|
172
|
+
it('should return paginated orders for a tenant', async () => {
|
|
173
|
+
const result = await adapter.getOrders('tenant-1');
|
|
174
|
+
expect(result.data).toBeInstanceOf(Array);
|
|
175
|
+
expect(result.total).toBeGreaterThanOrEqual(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should return null for non-existent order', async () => {
|
|
179
|
+
const result = await adapter.getOrderById('tenant-1', 'non-existent');
|
|
180
|
+
expect(result).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
```
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Architecture Pattern: Multi-Tenancy
|
|
2
|
+
|
|
3
|
+
## Tenant Context Propagation
|
|
4
|
+
|
|
5
|
+
Every request must carry tenant context. Extract once at the edge, propagate through the entire request lifecycle.
|
|
6
|
+
|
|
7
|
+
### Middleware
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
export function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
|
|
11
|
+
const tenantId = req.headers['x-tenant-id'] as string;
|
|
12
|
+
if (!tenantId) {
|
|
13
|
+
throw new AuthenticationError('Tenant ID required');
|
|
14
|
+
}
|
|
15
|
+
// Validate tenant exists and is active
|
|
16
|
+
req.tenantId = tenantId;
|
|
17
|
+
next();
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### AsyncLocalStorage for Deep Propagation
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
25
|
+
|
|
26
|
+
interface TenantContext {
|
|
27
|
+
tenantId: string;
|
|
28
|
+
userId?: string;
|
|
29
|
+
permissions?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const tenantStore = new AsyncLocalStorage<TenantContext>();
|
|
33
|
+
|
|
34
|
+
export function getCurrentTenant(): TenantContext {
|
|
35
|
+
const ctx = tenantStore.getStore();
|
|
36
|
+
if (!ctx) throw new Error('No tenant context — ensure tenantMiddleware is applied');
|
|
37
|
+
return ctx;
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Database Isolation Strategies
|
|
42
|
+
|
|
43
|
+
### Row-Level Security (Recommended)
|
|
44
|
+
|
|
45
|
+
```sql
|
|
46
|
+
-- Every table has a tenant_id column
|
|
47
|
+
ALTER TABLE orders ADD COLUMN tenant_id TEXT NOT NULL;
|
|
48
|
+
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
|
|
49
|
+
|
|
50
|
+
-- Prisma: always filter by tenant
|
|
51
|
+
const orders = await prisma.order.findMany({
|
|
52
|
+
where: { tenantId: getCurrentTenant().tenantId, ...filters },
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Query Scoping Pattern
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
/** Base repository that automatically scopes all queries by tenant */
|
|
60
|
+
export class TenantScopedRepository<T> {
|
|
61
|
+
constructor(private model: PrismaModel<T>) {}
|
|
62
|
+
|
|
63
|
+
async findMany(where: Partial<T>): Promise<T[]> {
|
|
64
|
+
const { tenantId } = getCurrentTenant();
|
|
65
|
+
return this.model.findMany({ where: { ...where, tenantId } });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async create(data: Omit<T, 'tenantId'>): Promise<T> {
|
|
69
|
+
const { tenantId } = getCurrentTenant();
|
|
70
|
+
return this.model.create({ data: { ...data, tenantId } });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Data Leakage Prevention
|
|
76
|
+
|
|
77
|
+
1. **Never trust client-provided tenant IDs for data access** — always derive from auth token
|
|
78
|
+
2. **Audit cross-tenant queries** — log and alert on any query that doesn't include tenant scoping
|
|
79
|
+
3. **Test isolation** — write tests that verify tenant A cannot see tenant B's data
|
|
80
|
+
4. **API response validation** — verify responses only contain data for the requesting tenant
|
|
81
|
+
|
|
82
|
+
## Tenant-Aware Caching
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
function cacheKey(key: string): string {
|
|
86
|
+
const { tenantId } = getCurrentTenant();
|
|
87
|
+
return `tenant:${tenantId}:${key}`;
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Testing Multi-Tenancy
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
describe('tenant isolation', () => {
|
|
95
|
+
it('should not return orders from other tenants', async () => {
|
|
96
|
+
await createOrder({ tenantId: 'tenant-a', item: 'Widget A' });
|
|
97
|
+
await createOrder({ tenantId: 'tenant-b', item: 'Widget B' });
|
|
98
|
+
|
|
99
|
+
const result = await getOrders('tenant-a');
|
|
100
|
+
expect(result.every((o) => o.tenantId === 'tenant-a')).toBe(true);
|
|
101
|
+
expect(result.some((o) => o.item === 'Widget B')).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
```
|