@3lineas/grooveskills 1.4.0 → 1.6.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/README.md +34 -25
- package/dist/{chunk-FN4OEDVV.js → chunk-HUG3MG3P.js} +396 -61
- package/dist/chunk-HUG3MG3P.js.map +1 -0
- package/dist/cli.js +6 -4
- package/dist/cli.js.map +1 -1
- package/dist/main.d.ts +1 -0
- package/dist/main.js +1 -1
- package/package.json +1 -1
- package/skills/auth-rbac-policies/SKILL.md +100 -0
- package/skills/auth-rbac-policies/references/auth-adapters-examples.md +99 -0
- package/skills/caching-rate-limiting/SKILL.md +109 -0
- package/skills/caching-rate-limiting/references/cache-rate-limit-examples.md +129 -0
- package/skills/clean-architecture-ddd/SKILL.md +1 -0
- package/skills/controller-orchestration-queues/SKILL.md +109 -0
- package/skills/controller-orchestration-queues/references/controller-queue-examples.md +115 -0
- package/skills/env-feature-flags/SKILL.md +94 -0
- package/skills/env-feature-flags/references/env-feature-flags-examples.md +93 -0
- package/skills/external-service-adapters/SKILL.md +148 -0
- package/skills/external-service-adapters/references/email-and-payment-examples.md +198 -0
- package/skills/groove-harness/SKILL.md +1 -0
- package/skills/storage-file-adapters/SKILL.md +125 -0
- package/skills/storage-file-adapters/references/storage-adapters-examples.md +164 -0
- package/skills/webhook-idempotency/SKILL.md +104 -0
- package/skills/webhook-idempotency/references/webhook-idempotency-examples.md +112 -0
- package/dist/chunk-FN4OEDVV.js.map +0 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Implementation Reference: Cache & Rate Limiting Adapters
|
|
2
|
+
|
|
3
|
+
Production implementations of Redis cache, Cloudflare KV, and sliding-window rate limiters.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Redis Cache Adapter (`ioredis`)
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// src/infrastructure/adapters/cache/redis-cache.adapter.ts
|
|
11
|
+
import type { Redis } from "ioredis";
|
|
12
|
+
import type { CacheStorePort } from "@/domains/cache/cache-store.port";
|
|
13
|
+
|
|
14
|
+
export class RedisCacheAdapter implements CacheStorePort {
|
|
15
|
+
constructor(private redis: Redis) {}
|
|
16
|
+
|
|
17
|
+
async get<T>(key: string): Promise<T | null> {
|
|
18
|
+
const raw = await this.redis.get(key);
|
|
19
|
+
if (!raw) return null;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(raw) as T;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
|
28
|
+
await this.redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async delete(key: string): Promise<void> {
|
|
32
|
+
await this.redis.del(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 2. Cloudflare Workers KV Cache Adapter
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// src/infrastructure/adapters/cache/cloudflare-kv.adapter.ts
|
|
43
|
+
import type { CacheStorePort } from "@/domains/cache/cache-store.port";
|
|
44
|
+
|
|
45
|
+
export class CloudflareKvAdapter implements CacheStorePort {
|
|
46
|
+
constructor(private kv: KVNamespace) {}
|
|
47
|
+
|
|
48
|
+
async get<T>(key: string): Promise<T | null> {
|
|
49
|
+
return this.kv.get<T>(key, "json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
|
53
|
+
await this.kv.put(key, JSON.stringify(value), { expirationTtl: Math.max(ttlSeconds, 60) });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async delete(key: string): Promise<void> {
|
|
57
|
+
await this.kv.delete(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 3. Redis Sliding Window Rate Limiter Adapter
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// src/infrastructure/adapters/rate-limit/redis-rate-limiter.adapter.ts
|
|
68
|
+
import type { Redis } from "ioredis";
|
|
69
|
+
import type { RateLimiterPort, RateLimitResult } from "@/domains/rate-limit/rate-limiter.port";
|
|
70
|
+
|
|
71
|
+
export class RedisSlidingWindowRateLimiter implements RateLimiterPort {
|
|
72
|
+
constructor(
|
|
73
|
+
private redis: Redis,
|
|
74
|
+
private maxRequests: number = 60,
|
|
75
|
+
private windowSeconds: number = 60
|
|
76
|
+
) {}
|
|
77
|
+
|
|
78
|
+
async limit(identifier: string): Promise<RateLimitResult> {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const windowStart = now - this.windowSeconds * 1000;
|
|
81
|
+
const key = `ratelimit:${identifier}`;
|
|
82
|
+
|
|
83
|
+
const multi = this.redis.multi();
|
|
84
|
+
multi.zremrangebyscore(key, 0, windowStart);
|
|
85
|
+
multi.zadd(key, now, `${now}-${Math.random()}`);
|
|
86
|
+
multi.zcard(key);
|
|
87
|
+
multi.expire(key, this.windowSeconds);
|
|
88
|
+
|
|
89
|
+
const results = await multi.exec();
|
|
90
|
+
const count = (results?.[2]?.[1] as number) ?? 1;
|
|
91
|
+
|
|
92
|
+
const remaining = Math.max(0, this.maxRequests - count);
|
|
93
|
+
const success = count <= this.maxRequests;
|
|
94
|
+
|
|
95
|
+
return { success, limit: this.maxRequests, remaining, resetSeconds: this.windowSeconds };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 4. In-Memory Testing Cache Adapter
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// src/infrastructure/adapters/cache/memory-cache.adapter.ts
|
|
106
|
+
import type { CacheStorePort } from "@/domains/cache/cache-store.port";
|
|
107
|
+
|
|
108
|
+
export class MemoryCacheAdapter implements CacheStorePort {
|
|
109
|
+
private store = new Map<string, { value: unknown; expiresAt: number }>();
|
|
110
|
+
|
|
111
|
+
async get<T>(key: string): Promise<T | null> {
|
|
112
|
+
const item = this.store.get(key);
|
|
113
|
+
if (!item) return null;
|
|
114
|
+
if (Date.now() > item.expiresAt) {
|
|
115
|
+
this.store.delete(key);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return item.value as T;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
|
122
|
+
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async delete(key: string): Promise<void> {
|
|
126
|
+
this.store.delete(key);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
@@ -101,3 +101,4 @@ export async function chargeSubscriptionAction(
|
|
|
101
101
|
- **`best-practices`**: Universal clean code rules and guard clauses.
|
|
102
102
|
- **`error-handling-logging`**: Structured `AppError` and `ActionResponse`.
|
|
103
103
|
- **`valibot-validation`**: Domain input/output validation.
|
|
104
|
+
- **`external-service-adapters`**: Standardized domain ports, vendor SDK wrappers, and adapters for third-party services.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: controller-orchestration-queues
|
|
3
|
+
description: Strict orchestration standards for Controllers, atomic Actions, database Transactions, and background Queue Adapters (Cloudflare Queues, BullMQ, AWS SQS, In-Memory). Mandates that Actions do only ONE atomic thing, while Controllers sequence multiple actions, manage transactions, or dispatch async jobs. Enforces Queue Workers as slim controllers. TRIGGER when orchestrating multi-step workflows, handling DB transactions across actions, dispatching background tasks, or designing queue consumers.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Controller Action Orchestration, Transactions & Queue Adapters
|
|
7
|
+
|
|
8
|
+
Architectural standard establishing strict separation of concerns between **Atomic Actions** and **Orchestration Controllers / Queue Workers**.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. The Core Architectural Law
|
|
13
|
+
|
|
14
|
+
1. **Actions Do Exactly ONE Atomic Thing**:
|
|
15
|
+
- Pure single responsibility. Example: `createOrderAction`, `chargeCardAction`, `sendOrderEmailAction`.
|
|
16
|
+
- Actions NEVER orchestrate other sibling actions or dispatch unrelated background jobs directly.
|
|
17
|
+
2. **Controllers are Orchestrators**:
|
|
18
|
+
- HTTP route handlers and RPC controllers validate inputs, sequence actions, wrap transactional DB boundaries (`withTransaction`), and dispatch background jobs via `QueuePort`.
|
|
19
|
+
3. **Queue Workers are Slim Controllers**:
|
|
20
|
+
- A queue worker message handler behaves like an HTTP controller: parses payload and delegates to atomic actions.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 2. Directory Layout
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
src/
|
|
28
|
+
├── actions/
|
|
29
|
+
│ ├── orders/
|
|
30
|
+
│ │ ├── create-order.action.ts # Atomic insert order (<70 lines)
|
|
31
|
+
│ │ └── update-order-status.action.ts # Atomic update status (<60 lines)
|
|
32
|
+
│ └── billing/
|
|
33
|
+
│ └── charge-card.action.ts # Atomic payment charge (<70 lines)
|
|
34
|
+
├── controllers/
|
|
35
|
+
│ └── orders/
|
|
36
|
+
│ └── checkout.controller.ts # Orchestrates actions + Queue (<90 lines)
|
|
37
|
+
├── domains/
|
|
38
|
+
│ └── queue/
|
|
39
|
+
│ ├── queue.port.ts # Domain interface for dispatching (<40 lines)
|
|
40
|
+
│ └── queue-message.types.ts # Strongly typed queue messages (<50 lines)
|
|
41
|
+
└── infrastructure/
|
|
42
|
+
├── adapters/
|
|
43
|
+
│ └── queue/
|
|
44
|
+
│ ├── cloudflare-queues.adapter.ts # Cloudflare Queues adapter (<80 lines)
|
|
45
|
+
│ ├── bullmq-queues.adapter.ts # Redis / BullMQ adapter (<80 lines)
|
|
46
|
+
│ ├── sqs-queues.adapter.ts # AWS SQS adapter (<80 lines)
|
|
47
|
+
│ └── memory-queues.adapter.ts # In-memory testing queue (<60 lines)
|
|
48
|
+
└── workers/
|
|
49
|
+
└── order-events.worker.ts # Slim worker invoking actions (<80 lines)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 3. Domain Queue Contract (`src/domains/queue/queue.port.ts`)
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
export interface QueueJob<TData = unknown> {
|
|
58
|
+
id?: string;
|
|
59
|
+
name: string;
|
|
60
|
+
data: TData;
|
|
61
|
+
delaySeconds?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface QueuePort {
|
|
65
|
+
dispatch<T>(job: QueueJob<T>): Promise<{ jobId: string }>;
|
|
66
|
+
dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }>;
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 4. Orchestration Controller Pattern
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// src/controllers/orders/checkout.controller.ts
|
|
76
|
+
import type { Context } from "hono";
|
|
77
|
+
import type { QueuePort } from "@/domains/queue/queue.port";
|
|
78
|
+
import { createOrderAction } from "@/actions/orders/create-order.action";
|
|
79
|
+
import { chargeCardAction } from "@/actions/billing/charge-card.action";
|
|
80
|
+
import { db, withTransaction } from "@/infrastructure/db/connection";
|
|
81
|
+
|
|
82
|
+
export async function checkoutController(c: Context, queue: QueuePort) {
|
|
83
|
+
const payload = c.get("validatedJson");
|
|
84
|
+
const actor = c.get("actor");
|
|
85
|
+
|
|
86
|
+
// Step 1: Charge card (external side-effect, kept outside DB transaction)
|
|
87
|
+
const charge = await chargeCardAction(payload.paymentMethodId, payload.amount);
|
|
88
|
+
|
|
89
|
+
// Step 2: Atomic DB Transaction for local entity creation
|
|
90
|
+
const order = await withTransaction(db, async (tx) => {
|
|
91
|
+
return createOrderAction(tx, { actorId: actor.id, amount: payload.amount, chargeId: charge.id });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Step 3: Dispatch async post-processing via Queue
|
|
95
|
+
await queue.dispatch({
|
|
96
|
+
name: "orders.post-checkout",
|
|
97
|
+
data: { orderId: order.id, customerEmail: actor.email },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return c.json({ orderId: order.id, status: "confirmed" }, 201);
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 5. Detailed Adapters & Workers Reference
|
|
107
|
+
|
|
108
|
+
For real implementations of Cloudflare Queues, BullMQ, AWS SQS, and In-Memory Queue adapters, consult:
|
|
109
|
+
- `references/controller-queue-examples.md`
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Implementation Reference: Controller Orchestration & Queue Adapters
|
|
2
|
+
|
|
3
|
+
Real-world queue adapters (Cloudflare Queues, BullMQ, AWS SQS, In-Memory) and Slim Worker controllers.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Cloudflare Queues Adapter
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// src/infrastructure/adapters/queue/cloudflare-queues.adapter.ts
|
|
11
|
+
import type { QueuePort, QueueJob } from "@/domains/queue/queue.port";
|
|
12
|
+
import { AppError } from "@/errors/app-error";
|
|
13
|
+
|
|
14
|
+
export class CloudflareQueueAdapter implements QueuePort {
|
|
15
|
+
constructor(private queue: Queue) {}
|
|
16
|
+
|
|
17
|
+
async dispatch<T>(job: QueueJob<T>): Promise<{ jobId: string }> {
|
|
18
|
+
try {
|
|
19
|
+
await this.queue.send(job, { delaySeconds: job.delaySeconds });
|
|
20
|
+
return { jobId: job.id ?? crypto.randomUUID() };
|
|
21
|
+
} catch (err) {
|
|
22
|
+
throw new AppError({ message: "Error al encolar mensaje en Cloudflare Queues.", statusCode: 502, cause: err });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }> {
|
|
27
|
+
await this.queue.sendBatch(jobs.map((j) => ({ body: j, delaySeconds: j.delaySeconds })));
|
|
28
|
+
return { count: jobs.length };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 2. BullMQ (Redis) Adapter
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// src/infrastructure/adapters/queue/bullmq-queues.adapter.ts
|
|
39
|
+
import { Queue as BullQueue } from "bullmq";
|
|
40
|
+
import type { QueuePort, QueueJob } from "@/domains/queue/queue.port";
|
|
41
|
+
import { AppError } from "@/errors/app-error";
|
|
42
|
+
|
|
43
|
+
export class BullMQAdapter implements QueuePort {
|
|
44
|
+
constructor(private bullQueue: BullQueue) {}
|
|
45
|
+
|
|
46
|
+
async dispatch<T>(job: QueueJob<T>): Promise<{ jobId: string }> {
|
|
47
|
+
try {
|
|
48
|
+
const bJob = await this.bullQueue.add(job.name, job.data, {
|
|
49
|
+
jobId: job.id,
|
|
50
|
+
delay: job.delaySeconds ? job.delaySeconds * 1000 : undefined,
|
|
51
|
+
});
|
|
52
|
+
return { jobId: String(bJob.id) };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new AppError({ message: "Error al enviar trabajo a BullMQ.", statusCode: 502, cause: err });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }> {
|
|
59
|
+
await this.bullQueue.addBulk(
|
|
60
|
+
jobs.map((j) => ({
|
|
61
|
+
name: j.name,
|
|
62
|
+
data: j.data,
|
|
63
|
+
opts: { jobId: j.id, delay: j.delaySeconds ? j.delaySeconds * 1000 : undefined },
|
|
64
|
+
}))
|
|
65
|
+
);
|
|
66
|
+
return { count: jobs.length };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 3. In-Memory Queue Adapter (Testing Fake)
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// src/infrastructure/adapters/queue/memory-queues.adapter.ts
|
|
77
|
+
import type { QueuePort, QueueJob } from "@/domains/queue/queue.port";
|
|
78
|
+
|
|
79
|
+
export class MemoryQueueAdapter implements QueuePort {
|
|
80
|
+
public jobs: QueueJob[] = [];
|
|
81
|
+
|
|
82
|
+
async dispatch<T>(job: QueueJob<T>): Promise<{ jobId: string }> {
|
|
83
|
+
const id = job.id ?? `job_${this.jobs.length + 1}`;
|
|
84
|
+
this.jobs.push({ ...job, id });
|
|
85
|
+
return { jobId: id };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async dispatchBatch<T>(jobs: QueueJob<T>[]): Promise<{ count: number }> {
|
|
89
|
+
for (const job of jobs) {
|
|
90
|
+
await this.dispatch(job);
|
|
91
|
+
}
|
|
92
|
+
return { count: jobs.length };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 4. Slim Queue Worker Controller Pattern
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// src/infrastructure/workers/order-events.worker.ts
|
|
103
|
+
import { sendOrderEmailAction } from "@/actions/email/send-order-email.action";
|
|
104
|
+
import { syncCrmAction } from "@/actions/crm/sync-crm.action";
|
|
105
|
+
import { logger } from "@/infrastructure/logger/app-logger";
|
|
106
|
+
|
|
107
|
+
export async function handleOrderQueueMessage(messageBody: { name: string; data: { orderId: string; customerEmail: string } }) {
|
|
108
|
+
logger.info("Procesando evento de orden", { jobName: messageBody.name, orderId: messageBody.data.orderId });
|
|
109
|
+
|
|
110
|
+
if (messageBody.name === "orders.post-checkout") {
|
|
111
|
+
await sendOrderEmailAction(messageBody.data.customerEmail, messageBody.data.orderId);
|
|
112
|
+
await syncCrmAction(messageBody.data.orderId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: env-feature-flags
|
|
3
|
+
description: Architectural standards for fail-fast environment validation with Valibot and decoupled Feature Flags wrappers with swappable adapters (PostHog, LaunchDarkly, Unleash, In-Memory). Guarantees that applications crash at boot time on invalid config and allows toggling features without coupling code to vendor SDKs. TRIGGER when reading environment variables, structuring process.env, configuring feature flags, or running A/B experiments.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Fail-Fast Environment Validation & Feature Flags Adapters
|
|
7
|
+
|
|
8
|
+
Opinionated architectural standard combining **Fail-Fast Environment Schema Validation** (using Valibot) and **Feature Flags Adapters** (PostHog, LaunchDarkly, In-Memory).
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Architectural Invariants
|
|
13
|
+
|
|
14
|
+
1. **Fail-Fast at Startup**:
|
|
15
|
+
- Environment variables must be validated on application boot. If a required secret or URL is missing or malformed, the process exits immediately with a clean diagnostic message.
|
|
16
|
+
- Raw `process.env` is NEVER accessed directly inside business actions or controllers. All code imports the strongly typed `env` singleton.
|
|
17
|
+
2. **Feature Flags as a Domain Port**:
|
|
18
|
+
- Business actions evaluate feature availability through `FeatureFlagPort`.
|
|
19
|
+
- Vendor SDKs (PostHog, LaunchDarkly, Unleash) remain isolated inside infrastructure adapters.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 2. Directory Layout
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
src/
|
|
27
|
+
├── config/
|
|
28
|
+
│ └── env.ts # Fail-fast Valibot environment parser (<70 lines)
|
|
29
|
+
├── domains/
|
|
30
|
+
│ └── feature-flags/
|
|
31
|
+
│ ├── feature-flag.port.ts # Domain interface for flags (<40 lines)
|
|
32
|
+
│ └── flag-keys.ts # Strongly typed flag registry (<30 lines)
|
|
33
|
+
└── infrastructure/
|
|
34
|
+
└── adapters/
|
|
35
|
+
└── feature-flags/
|
|
36
|
+
├── posthog-flags.adapter.ts # PostHog implementation (<70 lines)
|
|
37
|
+
├── launchdarkly-flags.adapter.ts # LaunchDarkly implementation (<70 lines)
|
|
38
|
+
└── memory-flags.adapter.ts # In-memory testing flags fake (<50 lines)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 3. Fail-Fast Environment Config (`src/config/env.ts`)
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import * as v from "valibot";
|
|
47
|
+
|
|
48
|
+
const EnvSchema = v.object({
|
|
49
|
+
NODE_ENV: v.fallback(v.picklist(["development", "production", "test"]), "development"),
|
|
50
|
+
PORT: v.fallback(v.pipe(v.string(), v.transform(Number)), 3000),
|
|
51
|
+
DATABASE_URL: v.pipe(v.string(), v.url("DATABASE_URL debe ser una URL válida.")),
|
|
52
|
+
API_SECRET_KEY: v.pipe(v.string(), v.minLength(32, "API_SECRET_KEY requiere al menos 32 caracteres.")),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function loadEnv() {
|
|
56
|
+
const result = v.safeParse(EnvSchema, process.env);
|
|
57
|
+
if (!result.success) {
|
|
58
|
+
console.error("❌ Error fatal en variables de entorno:");
|
|
59
|
+
for (const issue of result.issues) {
|
|
60
|
+
console.error(` - ${issue.path?.[0]?.key}: ${issue.message}`);
|
|
61
|
+
}
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
return result.output;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const env = loadEnv();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 4. Feature Flag Port (`src/domains/feature-flags/feature-flag.port.ts`)
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
export type FeatureFlagKey = "new-checkout-flow" | "ai-copilot" | "export-pdf";
|
|
76
|
+
|
|
77
|
+
export interface FeatureFlagEvaluationContext {
|
|
78
|
+
distinctId: string;
|
|
79
|
+
organizationId?: string;
|
|
80
|
+
properties?: Record<string, unknown>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface FeatureFlagPort {
|
|
84
|
+
isEnabled(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<boolean>;
|
|
85
|
+
getVariant<T = string>(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<T | null>;
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 5. Detailed Adapters Reference
|
|
92
|
+
|
|
93
|
+
For real implementations of PostHog, LaunchDarkly, and In-Memory Feature Flag adapters, see:
|
|
94
|
+
- `references/env-feature-flags-examples.md`
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Implementation Reference: Env & Feature Flag Adapters
|
|
2
|
+
|
|
3
|
+
Production implementations of PostHog, LaunchDarkly, and In-Memory testing feature flag adapters.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. PostHog Feature Flags Adapter (`posthog-node`)
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// src/infrastructure/adapters/feature-flags/posthog-flags.adapter.ts
|
|
11
|
+
import { PostHog } from "posthog-node";
|
|
12
|
+
import type { FeatureFlagPort, FeatureFlagKey, FeatureFlagEvaluationContext } from "@/domains/feature-flags/feature-flag.port";
|
|
13
|
+
|
|
14
|
+
export class PostHogFeatureFlagAdapter implements FeatureFlagPort {
|
|
15
|
+
constructor(private client: PostHog) {}
|
|
16
|
+
|
|
17
|
+
async isEnabled(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<boolean> {
|
|
18
|
+
const distinctId = context?.distinctId ?? "anonymous";
|
|
19
|
+
const res = await this.client.isFeatureEnabled(key, distinctId, {
|
|
20
|
+
personProperties: context?.properties,
|
|
21
|
+
});
|
|
22
|
+
return Boolean(res);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async getVariant<T = string>(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<T | null> {
|
|
26
|
+
const distinctId = context?.distinctId ?? "anonymous";
|
|
27
|
+
const variant = await this.client.getFeatureFlag(key, distinctId, {
|
|
28
|
+
personProperties: context?.properties,
|
|
29
|
+
});
|
|
30
|
+
return (variant as T) ?? null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 2. LaunchDarkly Feature Flags Adapter (`@launchdarkly/node-server-sdk`)
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// src/infrastructure/adapters/feature-flags/launchdarkly-flags.adapter.ts
|
|
41
|
+
import type { LDClient } from "@launchdarkly/node-server-sdk";
|
|
42
|
+
import type { FeatureFlagPort, FeatureFlagKey, FeatureFlagEvaluationContext } from "@/domains/feature-flags/feature-flag.port";
|
|
43
|
+
|
|
44
|
+
export class LaunchDarklyFeatureFlagAdapter implements FeatureFlagPort {
|
|
45
|
+
constructor(private client: LDClient) {}
|
|
46
|
+
|
|
47
|
+
async isEnabled(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<boolean> {
|
|
48
|
+
const user = {
|
|
49
|
+
key: context?.distinctId ?? "anonymous",
|
|
50
|
+
custom: { organizationId: context?.organizationId, ...context?.properties },
|
|
51
|
+
};
|
|
52
|
+
return this.client.variation(key, user, false);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getVariant<T = string>(key: FeatureFlagKey, context?: FeatureFlagEvaluationContext): Promise<T | null> {
|
|
56
|
+
const user = {
|
|
57
|
+
key: context?.distinctId ?? "anonymous",
|
|
58
|
+
custom: { organizationId: context?.organizationId, ...context?.properties },
|
|
59
|
+
};
|
|
60
|
+
return this.client.variation(key, user, null);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 3. In-Memory Testing Flags Adapter
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// src/infrastructure/adapters/feature-flags/memory-flags.adapter.ts
|
|
71
|
+
import type { FeatureFlagPort, FeatureFlagKey } from "@/domains/feature-flags/feature-flag.port";
|
|
72
|
+
|
|
73
|
+
export class MemoryFeatureFlagAdapter implements FeatureFlagPort {
|
|
74
|
+
private flags = new Map<string, boolean>();
|
|
75
|
+
private variants = new Map<string, unknown>();
|
|
76
|
+
|
|
77
|
+
setFlag(key: FeatureFlagKey, enabled: boolean): void {
|
|
78
|
+
this.flags.set(key, enabled);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setVariant(key: FeatureFlagKey, variant: unknown): void {
|
|
82
|
+
this.variants.set(key, variant);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async isEnabled(key: FeatureFlagKey): Promise<boolean> {
|
|
86
|
+
return this.flags.get(key) ?? false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getVariant<T = string>(key: FeatureFlagKey): Promise<T | null> {
|
|
90
|
+
return (this.variants.get(key) as T) ?? null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: external-service-adapters
|
|
3
|
+
description: Standardized wrapper & adapter architecture for external third-party services (payment gateways, transactional email, SMS, storage, AI providers). Enforces pure domain ports, isolated vendor SDK wrappers, unified input/output contracts, and interchangeable adapters. TRIGGER when integrating or refactoring any external API, payment provider (Stripe, MercadoPago), email service (Resend, SES, SMTP, Cloudflare), webhook, or cloud provider.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# External Service Adapters & Wrappers
|
|
7
|
+
|
|
8
|
+
Architectural pattern for standardizing third-party integrations (payments, emails, SMS, cloud storage, AI models) using the **Adapter Pattern** and **SDK Wrappers** to prevent vendor lock-in and keep business actions decoupled.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Architectural Distinction: Wrapper vs. Adapter
|
|
13
|
+
|
|
14
|
+
| Component | Responsibility | Location | Dependencies |
|
|
15
|
+
| :--- | :--- | :--- | :--- |
|
|
16
|
+
| **Domain Port (Interface)** | Defines the contract needed by our business domain (pure domain types). | `src/domains/<domain>/` | Zero external SDKs |
|
|
17
|
+
| **Vendor Wrapper (Client)** | Encapsulates vendor SDK initialization, raw authentication, and direct API calls. | `src/infrastructure/<vendor>/` | Vendor SDK / HTTP client |
|
|
18
|
+
| **Service Adapter** | Implements the Domain Port by calling the Vendor Wrapper and translating inputs/outputs. | `src/infrastructure/adapters/` | Port + Vendor Wrapper |
|
|
19
|
+
| **Business Action** | Executes use case logic depending ONLY on the Domain Port. | `src/actions/<domain>/` | Domain Port only |
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 2. The 4 Non-Negotiable Rules
|
|
24
|
+
|
|
25
|
+
1. **Actions Depend on Ports, Never Vendors**:
|
|
26
|
+
- Business actions must type-hint the domain port interface (`EmailSender`, `PaymentGateway`), never `Stripe`, `Resend`, or `aws-sdk`.
|
|
27
|
+
2. **Standardized Domain Contracts**:
|
|
28
|
+
- Input payloads and return values must use application domain types (e.g., `SendEmailInput`, `SendEmailResult`, `PaymentChargeResult`).
|
|
29
|
+
- Third-party SDK response shapes must never leak into actions or domain modules.
|
|
30
|
+
3. **Isolate SDK Quirks in Wrappers**:
|
|
31
|
+
- HTTP errors, retries, header formatting, and API keys stay inside the vendor wrapper.
|
|
32
|
+
- Exceptions are converted into standardized application errors (`AppError`) or `ActionResponse` failures.
|
|
33
|
+
4. **Interchangeability via Factory or Dependency Injection**:
|
|
34
|
+
- Swapping from `Resend` to `AWS SES` or `SMTP` requires only injecting a different adapter without altering a single line of business logic.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 3. Directory Layout
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
src/
|
|
42
|
+
├── actions/
|
|
43
|
+
│ └── billing/
|
|
44
|
+
│ └── process-payment.action.ts # Consumes PaymentGateway port (<80 lines)
|
|
45
|
+
├── domains/
|
|
46
|
+
│ ├── billing/
|
|
47
|
+
│ │ ├── payment-gateway.port.ts # Domain interface contract (<40 lines)
|
|
48
|
+
│ │ └── billing.types.ts # ChargeInput, ChargeResult (<50 lines)
|
|
49
|
+
│ └── email/
|
|
50
|
+
│ ├── email-sender.port.ts # Domain interface contract (<40 lines)
|
|
51
|
+
│ └── email.types.ts # SendEmailInput, SendEmailResult (<50 lines)
|
|
52
|
+
└── infrastructure/
|
|
53
|
+
├── adapters/
|
|
54
|
+
│ ├── email/
|
|
55
|
+
│ │ ├── resend-email.adapter.ts # Implements EmailSender (<80 lines)
|
|
56
|
+
│ │ ├── ses-email.adapter.ts # Implements EmailSender (<80 lines)
|
|
57
|
+
│ │ └── smtp-email.adapter.ts # Implements EmailSender (<80 lines)
|
|
58
|
+
│ └── payment/
|
|
59
|
+
│ ├── stripe-payment.adapter.ts # Implements PaymentGateway (<90 lines)
|
|
60
|
+
│ └── mercadopago-payment.adapter.ts # Implements PaymentGateway (<90 lines)
|
|
61
|
+
└── wrappers/
|
|
62
|
+
├── aws/
|
|
63
|
+
│ └── ses-client.wrapper.ts # Raw AWS SES client wrapper (<70 lines)
|
|
64
|
+
├── resend/
|
|
65
|
+
│ └── resend-client.wrapper.ts # Raw Resend SDK wrapper (<60 lines)
|
|
66
|
+
└── stripe/
|
|
67
|
+
└── stripe-client.wrapper.ts # Raw Stripe SDK wrapper (<60 lines)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 4. Implementation Blueprint (Email Example)
|
|
73
|
+
|
|
74
|
+
### Step 1: Pure Domain Port (`src/domains/email/email-sender.port.ts`)
|
|
75
|
+
```ts
|
|
76
|
+
export interface SendEmailPayload {
|
|
77
|
+
to: string | string[];
|
|
78
|
+
subject: string;
|
|
79
|
+
htmlContent: string;
|
|
80
|
+
from?: string;
|
|
81
|
+
replyTo?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SendEmailOutput {
|
|
85
|
+
id: string;
|
|
86
|
+
delivered: boolean;
|
|
87
|
+
timestamp: Date;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface EmailSenderPort {
|
|
91
|
+
sendEmail(payload: SendEmailPayload): Promise<SendEmailOutput>;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Step 2: Vendor Wrapper (`src/infrastructure/wrappers/resend/resend.wrapper.ts`)
|
|
96
|
+
```ts
|
|
97
|
+
import { Resend } from "resend";
|
|
98
|
+
|
|
99
|
+
export class ResendWrapper {
|
|
100
|
+
private readonly client: Resend;
|
|
101
|
+
|
|
102
|
+
constructor(apiKey: string) {
|
|
103
|
+
this.client = new Resend(apiKey);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async send(params: { from: string; to: string[]; subject: string; html: string }) {
|
|
107
|
+
return this.client.emails.send(params);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Step 3: Domain Adapter (`src/infrastructure/adapters/email/resend-email.adapter.ts`)
|
|
113
|
+
```ts
|
|
114
|
+
import type { EmailSenderPort, SendEmailPayload, SendEmailOutput } from "@/domains/email/email-sender.port";
|
|
115
|
+
import type { ResendWrapper } from "@/infrastructure/wrappers/resend/resend.wrapper";
|
|
116
|
+
import { AppError } from "@/errors/app-error";
|
|
117
|
+
|
|
118
|
+
export class ResendEmailAdapter implements EmailSenderPort {
|
|
119
|
+
constructor(
|
|
120
|
+
private readonly wrapper: ResendWrapper,
|
|
121
|
+
private readonly defaultFrom: string
|
|
122
|
+
) {}
|
|
123
|
+
|
|
124
|
+
async sendEmail(payload: SendEmailPayload): Promise<SendEmailOutput> {
|
|
125
|
+
const recipients = Array.isArray(payload.to) ? payload.to : [payload.to];
|
|
126
|
+
const { data, error } = await this.wrapper.send({
|
|
127
|
+
from: payload.from ?? this.defaultFrom,
|
|
128
|
+
to: recipients,
|
|
129
|
+
subject: payload.subject,
|
|
130
|
+
html: payload.htmlContent,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (error || !data) {
|
|
134
|
+
throw new AppError({
|
|
135
|
+
message: `Error al enviar correo vía Resend: ${error?.message ?? "desconocido"}`,
|
|
136
|
+
statusCode: 502,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { id: data.id, delivered: true, timestamp: new Date() };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 5. Reference Guides
|
|
148
|
+
- Detailed concrete implementations for **Email** (SMTP, Resend, AWS SES, Cloudflare Email) and **Payment Gateways** (Stripe, MercadoPago) can be found in `references/email-and-payment-examples.md`.
|