Compare commits
119 Commits
seguimient
...
chore/clau
| Author | SHA1 | Date | |
|---|---|---|---|
| 16350e5862 | |||
| 609cd2f8e4 | |||
| 3186b438c0 | |||
| 5e77619d37 | |||
| b67d53c855 | |||
| 9a5308c3c9 | |||
| c9733113cf | |||
| 4c1d6ac2c4 | |||
| 07e7a0d457 | |||
| 48361ab33f | |||
| a3c7c224b1 | |||
| 324aec3001 | |||
| 05e941710b | |||
| f78a333e1e | |||
| 01c55cba0f | |||
| 10b2ae244c | |||
| 2dba2ebfae | |||
| d7eb4ad326 | |||
| d818441bde | |||
| c91965567d | |||
| d063b47bec | |||
| 6112de297b | |||
| 166c940295 | |||
| 246e4cb83b | |||
| 4517796ef3 | |||
| e1450c6e97 | |||
| e40a19bbfb | |||
| fbdb64f3a1 | |||
| 9a29f49669 | |||
| c2081191ae | |||
| f0f3827fd0 | |||
| ee8f84bc57 | |||
| f95677d503 | |||
| 59b0b57ec2 | |||
| 9174b0b6a4 | |||
| e62c49ce91 | |||
| 32990b4dcd | |||
| da2413002b | |||
| fdbb81ba64 | |||
| 964ea6add9 | |||
| 602878acf4 | |||
| 0aa52feaac | |||
| 15b70309da | |||
| 7001fccbf7 | |||
| cffee785b2 | |||
| 33d260310c | |||
| e359acc1d5 | |||
| bb4bce4a6d | |||
| eac74ef0cd | |||
| 1dc4eb5648 | |||
| a35a6c2b60 | |||
| 1f78f4a3e1 | |||
| 1e98559f3a | |||
| ef0f860b9d | |||
| 0bff55379f | |||
| 4d34308a13 | |||
| 70bf73b0a4 | |||
| e3849d8217 | |||
| d9854a12a8 | |||
| 48d387a8da | |||
| 93d3e13793 | |||
| 031f5d5cf0 | |||
| 047669bab2 | |||
| 5ea5939e3a | |||
| 7ff3f13af4 | |||
| a9589f578b | |||
| a27e4b30d2 | |||
| 4168949b9e | |||
| e6ff54a15d | |||
| 3956797020 | |||
| 7d88359263 | |||
| 1b6da651a6 | |||
| 9b305f887f | |||
| 9506b9e28e | |||
| 61c0edca07 | |||
| 9470b5605d | |||
| 9d63d23754 | |||
| a95655a2a6 | |||
| 025801a689 | |||
| 28880c4d99 | |||
| 5bb3bc554b | |||
| cfb907b840 | |||
| d5d7953fd2 | |||
| 96298aab25 | |||
| c17cca1e81 | |||
| 7264efcf79 | |||
| 8934bcd603 | |||
| bdd08dbc56 | |||
| 7d47fde806 | |||
| ad207fb732 | |||
| bd9081b5bc | |||
| a429e9d14a | |||
| 81eb986313 | |||
| 58bedc42f1 | |||
| b97f422261 | |||
| 7a7dc33724 | |||
| 7743bd1f0d | |||
| 2897d7aa3c | |||
| 0fd7eafcf3 | |||
| 71253d216e | |||
| aeea6cfefd | |||
| e8eb925834 | |||
| 7cf9cc60e6 | |||
| 1e9818d430 | |||
| 39c0e87758 | |||
| 5771972e2a | |||
| ea13403dc3 | |||
| 8d9a9b84b8 | |||
| 9b92f3506b | |||
| 1798118f6b | |||
| eba2b8c569 | |||
| b6b2cf6cc8 | |||
| a0faa2d105 | |||
| d323f804fc | |||
| 978454754c | |||
| b6091b15da | |||
| a6794a061b | |||
| fafea3ce04 | |||
| 992f639f35 |
169
.agents/skills/clean-ddd-hexagonal/SKILL.md
Normal file
169
.agents/skills/clean-ddd-hexagonal/SKILL.md
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: clean-ddd-hexagonal
|
||||
description: Proactively apply when designing APIs, microservices, or scalable backend structure. Triggers on DDD, Clean Architecture, Hexagonal, ports and adapters, entities, value objects, domain events, CQRS, event sourcing, repository pattern, use cases, onion architecture, outbox pattern, aggregate root, anti-corruption layer. Use when working with domain models, aggregates, repositories, or bounded contexts. Clean Architecture + DDD + Hexagonal patterns for backend services, language-agnostic (Go, Rust, Python, TypeScript, Java, C#).
|
||||
---
|
||||
|
||||
# Clean Architecture + DDD + Hexagonal
|
||||
|
||||
Backend architecture combining DDD tactical patterns, Clean Architecture dependency rules, and Hexagonal ports/adapters for maintainable, testable systems.
|
||||
|
||||
## When to Use (and When NOT to)
|
||||
|
||||
| Use When | Skip When |
|
||||
|----------|-----------|
|
||||
| Complex business domain with many rules | Simple CRUD, few business rules |
|
||||
| Long-lived system (years of maintenance) | Prototype, MVP, throwaway code |
|
||||
| Team of 5+ developers | Solo developer or small team (1-2) |
|
||||
| Multiple entry points (API, CLI, events) | Single entry point, simple API |
|
||||
| Need to swap infrastructure (DB, broker) | Fixed infrastructure, unlikely to change |
|
||||
| High test coverage required | Quick scripts, internal tools |
|
||||
|
||||
**Start simple. Evolve complexity only when needed.** Most systems don't need full CQRS or Event Sourcing.
|
||||
|
||||
## CRITICAL: The Dependency Rule
|
||||
|
||||
Dependencies point **inward only**. Outer layers depend on inner layers, never the reverse.
|
||||
|
||||
```
|
||||
Infrastructure → Application → Domain
|
||||
(adapters) (use cases) (core)
|
||||
```
|
||||
|
||||
**Violations to catch:**
|
||||
- Domain importing database/HTTP libraries
|
||||
- Controllers calling repositories directly (bypassing use cases)
|
||||
- Entities depending on application services
|
||||
|
||||
**Design validation:** "Create your application to work without either a UI or a database" — Alistair Cockburn. If you can run your domain logic from tests with no infrastructure, your boundaries are correct.
|
||||
|
||||
## Quick Decision Trees
|
||||
|
||||
### "Where does this code go?"
|
||||
|
||||
```
|
||||
Where does it go?
|
||||
├─ Pure business logic, no I/O → domain/
|
||||
├─ Orchestrates domain + has side effects → application/
|
||||
├─ Talks to external systems → infrastructure/
|
||||
├─ Defines HOW to interact (interface) → port (domain or application)
|
||||
└─ Implements a port → adapter (infrastructure)
|
||||
```
|
||||
|
||||
### "Is this an Entity or Value Object?"
|
||||
|
||||
```
|
||||
Entity or Value Object?
|
||||
├─ Has unique identity that persists → Entity
|
||||
├─ Defined only by its attributes → Value Object
|
||||
├─ "Is this THE same thing?" → Entity (identity comparison)
|
||||
└─ "Does this have the same value?" → Value Object (structural equality)
|
||||
```
|
||||
|
||||
### "Should this be its own Aggregate?"
|
||||
|
||||
```
|
||||
Aggregate boundaries?
|
||||
├─ Must be consistent together in a transaction → Same aggregate
|
||||
├─ Can be eventually consistent → Separate aggregates
|
||||
├─ Referenced by ID only → Separate aggregates
|
||||
└─ >10 entities in aggregate → Split it
|
||||
```
|
||||
|
||||
**Rule:** One aggregate per transaction. Cross-aggregate consistency via domain events (eventual consistency).
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── domain/ # Core business logic (NO external dependencies)
|
||||
│ ├── {aggregate}/
|
||||
│ │ ├── entity # Aggregate root + child entities
|
||||
│ │ ├── value_objects # Immutable value types
|
||||
│ │ ├── events # Domain events
|
||||
│ │ ├── repository # Repository interface (DRIVEN PORT)
|
||||
│ │ └── services # Domain services (stateless logic)
|
||||
│ └── shared/
|
||||
│ └── errors # Domain errors
|
||||
├── application/ # Use cases / Application services
|
||||
│ ├── {use-case}/
|
||||
│ │ ├── command # Command/Query DTOs
|
||||
│ │ ├── handler # Use case implementation
|
||||
│ │ └── port # Driver port interface
|
||||
│ └── shared/
|
||||
│ └── unit_of_work # Transaction abstraction
|
||||
├── infrastructure/ # Adapters (external concerns)
|
||||
│ ├── persistence/ # Database adapters
|
||||
│ ├── messaging/ # Message broker adapters
|
||||
│ ├── http/ # REST/GraphQL adapters (DRIVER)
|
||||
│ └── config/
|
||||
│ └── di # Dependency injection / composition root
|
||||
└── main # Bootstrap / entry point
|
||||
```
|
||||
|
||||
## DDD Building Blocks
|
||||
|
||||
| Pattern | Purpose | Layer | Key Rule |
|
||||
|---------|---------|-------|----------|
|
||||
| **Entity** | Identity + behavior | Domain | Equality by ID |
|
||||
| **Value Object** | Immutable data | Domain | Equality by value, no setters |
|
||||
| **Aggregate** | Consistency boundary | Domain | Only root is referenced externally |
|
||||
| **Domain Event** | Record of change | Domain | Past tense naming (`OrderPlaced`) |
|
||||
| **Repository** | Persistence abstraction | Domain (port) | Per aggregate, not per table |
|
||||
| **Domain Service** | Stateless logic | Domain | When logic doesn't fit an entity |
|
||||
| **Application Service** | Orchestration | Application | Coordinates domain + infra |
|
||||
|
||||
## Anti-Patterns (CRITICAL)
|
||||
|
||||
| Anti-Pattern | Problem | Fix |
|
||||
|--------------|---------|-----|
|
||||
| **Anemic Domain Model** | Entities are data bags, logic in services | Move behavior INTO entities |
|
||||
| **Repository per Entity** | Breaks aggregate boundaries | One repository per AGGREGATE |
|
||||
| **Leaking Infrastructure** | Domain imports DB/HTTP libs | Domain has ZERO external deps |
|
||||
| **God Aggregate** | Too many entities, slow transactions | Split into smaller aggregates |
|
||||
| **Skipping Ports** | Controllers → Repositories directly | Always go through application layer |
|
||||
| **CRUD Thinking** | Modeling data, not behavior | Model business operations |
|
||||
| **Premature CQRS** | Adding complexity before needed | Start with simple read/write, evolve |
|
||||
| **Cross-Aggregate TX** | Multiple aggregates in one transaction | Use domain events for consistency |
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Discover the Domain** — Event Storming, conversations with domain experts
|
||||
2. **Model the Domain** — Entities, value objects, aggregates (no infra)
|
||||
3. **Define Ports** — Repository interfaces, external service interfaces
|
||||
4. **Implement Use Cases** — Application services coordinating domain
|
||||
5. **Add Adapters last** — HTTP, database, messaging implementations
|
||||
|
||||
**DDD is collaborative.** Modeling sessions with domain experts are as important as the code patterns.
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| [references/LAYERS.md](references/LAYERS.md) | Complete layer specifications |
|
||||
| [references/DDD-STRATEGIC.md](references/DDD-STRATEGIC.md) | Bounded contexts, context mapping |
|
||||
| [references/DDD-TACTICAL.md](references/DDD-TACTICAL.md) | Entities, value objects, aggregates (pseudocode) |
|
||||
| [references/HEXAGONAL.md](references/HEXAGONAL.md) | Ports, adapters, naming |
|
||||
| [references/CQRS-EVENTS.md](references/CQRS-EVENTS.md) | Command/query separation, events |
|
||||
| [references/TESTING.md](references/TESTING.md) | Unit, integration, architecture tests |
|
||||
| [references/CHEATSHEET.md](references/CHEATSHEET.md) | Quick decision guide |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary Sources
|
||||
- [The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) — Robert C. Martin (2012)
|
||||
- [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) — Alistair Cockburn (2005)
|
||||
- [Domain-Driven Design: The Blue Book](https://www.domainlanguage.com/ddd/blue-book/) — Eric Evans (2003)
|
||||
- [Implementing Domain-Driven Design](https://openlibrary.org/works/OL17392277W) — Vaughn Vernon (2013)
|
||||
|
||||
### Pattern References
|
||||
- [CQRS](https://martinfowler.com/bliki/CQRS.html) — Martin Fowler
|
||||
- [Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) — Martin Fowler
|
||||
- [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) — Martin Fowler (PoEAA)
|
||||
- [Unit of Work](https://martinfowler.com/eaaCatalog/unitOfWork.html) — Martin Fowler (PoEAA)
|
||||
- [Bounded Context](https://martinfowler.com/bliki/BoundedContext.html) — Martin Fowler
|
||||
- [Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html) — microservices.io
|
||||
- [Effective Aggregate Design](https://www.dddcommunity.org/library/vernon_2011/) — Vaughn Vernon
|
||||
|
||||
### Implementation Guides
|
||||
- [Microsoft: DDD + CQRS Microservices](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/)
|
||||
- [Domain Events](https://udidahan.com/2009/06/14/domain-events-salvation/) — Udi Dahan
|
||||
406
.agents/skills/clean-ddd-hexagonal/references/CHEATSHEET.md
Normal file
406
.agents/skills/clean-ddd-hexagonal/references/CHEATSHEET.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# Quick Reference Cheatsheet
|
||||
|
||||
> See [SKILL.md](../SKILL.md#sources) for full source list.
|
||||
|
||||
## Layer Summary
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Infra["INFRASTRUCTURE (Adapters)"]
|
||||
I1["REST/gRPC controllers"]
|
||||
I2["CLI handlers"]
|
||||
I3["Framework code"]
|
||||
I4["Database repositories"]
|
||||
I5["Message publishers"]
|
||||
I6["External service clients"]
|
||||
end
|
||||
|
||||
subgraph App["APPLICATION (Use Cases)"]
|
||||
A1["Command/Query handlers"]
|
||||
A2["DTOs"]
|
||||
A3["Transaction management"]
|
||||
A4["Port interfaces"]
|
||||
A5["Application services"]
|
||||
A6["Event dispatching"]
|
||||
end
|
||||
|
||||
subgraph Domain["DOMAIN (Business Logic)"]
|
||||
D1["Entities"]
|
||||
D2["Aggregates"]
|
||||
D3["Repository interfaces"]
|
||||
D4["Business rules"]
|
||||
D5["Value Objects"]
|
||||
D6["Domain Events"]
|
||||
D7["Domain Services"]
|
||||
D8["Specifications"]
|
||||
end
|
||||
|
||||
Infra -->|depends on| App
|
||||
App -->|depends on| Domain
|
||||
|
||||
style Infra fill:#6366f1,stroke:#4f46e5,color:white
|
||||
style App fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style Domain fill:#10b981,stroke:#059669,color:white
|
||||
```
|
||||
|
||||
*Dependencies point inward*
|
||||
|
||||
---
|
||||
|
||||
## Quick Decision Trees
|
||||
|
||||
### "Where does this code go?"
|
||||
|
||||
```
|
||||
Is it a business rule or constraint?
|
||||
├── YES → Domain layer
|
||||
└── NO ↓
|
||||
|
||||
Is it orchestrating a use case?
|
||||
├── YES → Application layer
|
||||
└── NO ↓
|
||||
|
||||
Is it dealing with external systems (DB, API, UI)?
|
||||
├── YES → Infrastructure layer
|
||||
└── NO → Reconsider; probably domain
|
||||
```
|
||||
|
||||
### "Entity or Value Object?"
|
||||
|
||||
```
|
||||
Does it have a unique identity that persists?
|
||||
├── YES → Entity
|
||||
└── NO ↓
|
||||
|
||||
Is it defined entirely by its attributes?
|
||||
├── YES → Value Object
|
||||
└── NO → Probably an Entity
|
||||
```
|
||||
|
||||
### "Aggregate boundary?"
|
||||
|
||||
```
|
||||
Must these objects change together atomically?
|
||||
├── YES → Same aggregate
|
||||
└── NO ↓
|
||||
|
||||
Can one exist without the other?
|
||||
├── YES → Different aggregates (reference by ID)
|
||||
└── NO → Probably same aggregate
|
||||
```
|
||||
|
||||
### "Domain Service or Entity method?"
|
||||
|
||||
```
|
||||
Does it naturally belong to one entity?
|
||||
├── YES → Entity method
|
||||
└── NO ↓
|
||||
|
||||
Does it require multiple aggregates?
|
||||
├── YES → Domain Service
|
||||
└── NO ↓
|
||||
|
||||
Is it stateless business logic?
|
||||
├── YES → Domain Service
|
||||
└── NO → Reconsider placement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns Quick Reference
|
||||
|
||||
### Value Object Template
|
||||
|
||||
```typescript
|
||||
export class Money {
|
||||
private constructor(
|
||||
private readonly _amount: number,
|
||||
private readonly _currency: string,
|
||||
) {}
|
||||
|
||||
static create(amount: number, currency: string): Money {
|
||||
if (amount < 0) throw new Error('Negative');
|
||||
return new Money(amount, currency);
|
||||
}
|
||||
|
||||
add(other: Money): Money {
|
||||
return Money.create(this._amount + other._amount, this._currency);
|
||||
}
|
||||
|
||||
get amount(): number { return this._amount; }
|
||||
get currency(): string { return this._currency; }
|
||||
|
||||
equals(other: Money): boolean {
|
||||
return this._amount === other._amount && this._currency === other._currency;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Entity Template
|
||||
|
||||
```typescript
|
||||
export class OrderItem extends Entity<OrderItemId> {
|
||||
private _quantity: Quantity;
|
||||
|
||||
private constructor(id: OrderItemId, private readonly _productId: ProductId, quantity: Quantity) {
|
||||
super(id);
|
||||
this._quantity = quantity;
|
||||
}
|
||||
|
||||
static create(productId: ProductId, quantity: Quantity): OrderItem {
|
||||
return new OrderItem(OrderItemId.generate(), productId, quantity);
|
||||
}
|
||||
|
||||
increaseQuantity(amount: number): void {
|
||||
this._quantity = this._quantity.add(amount);
|
||||
}
|
||||
|
||||
get productId(): ProductId { return this._productId; }
|
||||
get quantity(): Quantity { return this._quantity; }
|
||||
}
|
||||
```
|
||||
|
||||
### Aggregate Root Template
|
||||
|
||||
```typescript
|
||||
export class Order extends AggregateRoot<OrderId> {
|
||||
private _items: OrderItem[] = [];
|
||||
private _status: OrderStatus;
|
||||
|
||||
private constructor(id: OrderId, customerId: CustomerId) {
|
||||
super(id);
|
||||
this._customerId = customerId;
|
||||
this._status = OrderStatus.Draft;
|
||||
}
|
||||
|
||||
static create(customerId: CustomerId): Order {
|
||||
const order = new Order(OrderId.generate(), customerId);
|
||||
order.addDomainEvent(new OrderCreated(order.id, customerId));
|
||||
return order;
|
||||
}
|
||||
|
||||
addItem(productId: ProductId, quantity: Quantity, price: Money): void {
|
||||
this.assertCanModify();
|
||||
this._items.push(OrderItem.create(productId, quantity, price));
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
this.assertCanModify();
|
||||
if (this._items.length === 0) throw new EmptyOrderError();
|
||||
this._status = OrderStatus.Confirmed;
|
||||
this.addDomainEvent(new OrderConfirmed(this.id, this.total));
|
||||
}
|
||||
|
||||
private assertCanModify(): void {
|
||||
if (this._status === OrderStatus.Cancelled) {
|
||||
throw new InvalidOrderStateError('Order is cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
get total(): Money { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Interface Template
|
||||
|
||||
```typescript
|
||||
export interface IOrderRepository {
|
||||
findById(id: OrderId): Promise<Order | null>;
|
||||
save(order: Order): Promise<void>;
|
||||
delete(order: Order): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Use Case Handler Template
|
||||
|
||||
```typescript
|
||||
export class PlaceOrderHandler {
|
||||
constructor(
|
||||
private readonly orderRepo: IOrderRepository,
|
||||
private readonly productRepo: IProductRepository,
|
||||
private readonly eventPublisher: IEventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: PlaceOrderCommand): Promise<OrderId> {
|
||||
const order = Order.create(CustomerId.from(command.customerId));
|
||||
|
||||
for (const item of command.items) {
|
||||
const product = await this.productRepo.findById(item.productId);
|
||||
order.addItem(product.id, Quantity.create(item.quantity), product.price);
|
||||
}
|
||||
|
||||
await this.orderRepo.save(order);
|
||||
await this.eventPublisher.publishAll(order.domainEvents);
|
||||
|
||||
return order.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Port Naming Conventions
|
||||
|
||||
| Type | Pattern | Examples |
|
||||
|------|---------|----------|
|
||||
| Driver Port | `I{Action}UseCase` | `IPlaceOrderUseCase`, `IGetOrderUseCase` |
|
||||
| Driven Port | `I{Resource}Repository` | `IOrderRepository`, `IProductRepository` |
|
||||
| Driven Port | `I{Action}Service` | `IPaymentService`, `INotificationService` |
|
||||
| Driven Port | `I{Resource}Gateway` | `IPaymentGateway`, `IShippingGateway` |
|
||||
|
||||
---
|
||||
|
||||
## Common Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
|--------------|---------|----------|
|
||||
| Anemic Domain | Entities are just data bags | Put behavior in entities |
|
||||
| Repository per table | One repo per DB table | One repo per aggregate |
|
||||
| Fat Use Cases | Business logic in handlers | Move to domain |
|
||||
| Leaky Abstraction | Domain depends on ORM | Keep domain pure |
|
||||
| God Aggregate | One massive aggregate | Split into smaller ones |
|
||||
| Cross-Aggregate TX | Modifying multiple in one TX | Use domain events |
|
||||
| Direct Layer Skip | Controller → Repository | Go through application layer |
|
||||
| Premature CQRS | Adding complexity early | Start simple, evolve |
|
||||
| Event Proliferation | Too many fine-grained events | May signal context boundary |
|
||||
|
||||
---
|
||||
|
||||
## Dependency Rules Matrix
|
||||
|
||||
| | Domain | Application | Infrastructure |
|
||||
|--|--------|-------------|----------------|
|
||||
| **Domain** | ✅ | ❌ | ❌ |
|
||||
| **Application** | ✅ | ✅ | ❌ |
|
||||
| **Infrastructure** | ✅ | ✅ | ✅ |
|
||||
|
||||
✅ = Can depend on
|
||||
❌ = Cannot depend on
|
||||
|
||||
---
|
||||
|
||||
## Hexagonal Quick Reference
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Driver["DRIVER (Left/Primary/Inbound)"]
|
||||
direction TB
|
||||
D1["REST Controller"]
|
||||
D2["gRPC Service"]
|
||||
D3["CLI Command"]
|
||||
D4["Message Consumer"]
|
||||
DP["Port (Interface)"]
|
||||
D1 & D2 & D3 & D4 -->|calls| DP
|
||||
end
|
||||
|
||||
subgraph App["Application"]
|
||||
Core[" "]
|
||||
end
|
||||
|
||||
subgraph Driven["DRIVEN (Right/Secondary/Outbound)"]
|
||||
direction TB
|
||||
DRP["Port (Interface)"]
|
||||
DR1["Database Repository"]
|
||||
DR2["Message Publisher"]
|
||||
DR3["External API Client"]
|
||||
DR4["Cache Adapter"]
|
||||
DR1 & DR2 & DR3 & DR4 -->|implements| DRP
|
||||
end
|
||||
|
||||
Driver -->|"How world\nuses app"| App
|
||||
App -->|"How app\nuses world"| Driven
|
||||
|
||||
style Driver fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style App fill:#10b981,stroke:#059669,color:white
|
||||
style Driven fill:#f59e0b,stroke:#d97706,color:white
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to Use / Skip
|
||||
|
||||
### Use Clean + DDD + Hexagonal When:
|
||||
|
||||
- ✅ Complex business domain with many rules
|
||||
- ✅ Long-lived system (years of maintenance)
|
||||
- ✅ Large team (5+ developers)
|
||||
- ✅ Need to swap infrastructure (DB, broker, etc.)
|
||||
- ✅ High test coverage required
|
||||
- ✅ Multiple entry points (API, CLI, events, scheduled jobs)
|
||||
|
||||
### Skip When:
|
||||
|
||||
- ❌ Simple CRUD application (most applications)
|
||||
- ❌ Prototype / MVP / throwaway code
|
||||
- ❌ Small team (1-2 devs)
|
||||
- ❌ Short-lived project
|
||||
- ❌ Trivial business logic
|
||||
|
||||
### Complexity Ladder (Start Simple)
|
||||
|
||||
```
|
||||
Level 1: Simple layered (Controller → Service → Repository)
|
||||
↓ When business rules grow complex
|
||||
Level 2: Domain model (Entities with behavior)
|
||||
↓ When need multiple entry points
|
||||
Level 3: Hexagonal (Ports & Adapters)
|
||||
↓ When read/write patterns diverge significantly
|
||||
Level 4: CQRS (Separate read/write models)
|
||||
↓ When need complete audit trail / temporal queries
|
||||
Level 5: Event Sourcing (Store events, derive state)
|
||||
```
|
||||
|
||||
**Don't skip levels.** Each level adds complexity. Move up only when you've proven the current level insufficient.
|
||||
|
||||
---
|
||||
|
||||
## File Naming Conventions
|
||||
|
||||
```
|
||||
domain/
|
||||
├── order/
|
||||
│ ├── order.ts # Aggregate root
|
||||
│ ├── order_item.ts # Entity
|
||||
│ ├── value_objects.ts # OrderId, Money, etc.
|
||||
│ ├── events.ts # OrderCreated, etc.
|
||||
│ ├── repository.ts # IOrderRepository
|
||||
│ ├── services.ts # Domain services
|
||||
│ └── errors.ts # OrderError, etc.
|
||||
|
||||
application/
|
||||
├── place_order/
|
||||
│ ├── command.ts # PlaceOrderCommand
|
||||
│ ├── handler.ts # PlaceOrderHandler
|
||||
│ └── port.ts # IPlaceOrderUseCase
|
||||
|
||||
infrastructure/
|
||||
├── postgres/
|
||||
│ ├── order_repository.ts # PostgresOrderRepository
|
||||
│ └── mappers/
|
||||
│ └── order_mapper.ts # Domain <-> DB mapping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
### Books
|
||||
- Clean Architecture (Robert C. Martin, 2017)
|
||||
- Domain-Driven Design (Eric Evans, 2003)
|
||||
- Implementing Domain-Driven Design (Vaughn Vernon, 2013)
|
||||
- Hexagonal Architecture Explained (Alistair Cockburn, 2024)
|
||||
- Get Your Hands Dirty on Clean Architecture (Tom Hombergs, 2019)
|
||||
|
||||
### Reference Implementations
|
||||
- Go: [bxcodec/go-clean-arch](https://github.com/bxcodec/go-clean-arch)
|
||||
- Rust: [flosse/clean-architecture-with-rust](https://github.com/flosse/clean-architecture-with-rust)
|
||||
- Python: [cdddg/py-clean-arch](https://github.com/cdddg/py-clean-arch)
|
||||
- TypeScript: [jbuget/nodejs-clean-architecture-app](https://github.com/jbuget/nodejs-clean-architecture-app)
|
||||
- .NET: [jasontaylordev/CleanArchitecture](https://github.com/jasontaylordev/CleanArchitecture)
|
||||
- Java: [thombergs/buckpal](https://github.com/thombergs/buckpal)
|
||||
|
||||
### Official Documentation
|
||||
- https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
|
||||
- https://alistair.cockburn.us/hexagonal-architecture/
|
||||
- https://www.domainlanguage.com/ddd/
|
||||
- https://martinfowler.com/tags/domain%20driven%20design.html
|
||||
639
.agents/skills/clean-ddd-hexagonal/references/CQRS-EVENTS.md
Normal file
639
.agents/skills/clean-ddd-hexagonal/references/CQRS-EVENTS.md
Normal file
@@ -0,0 +1,639 @@
|
||||
# CQRS & Domain Events
|
||||
|
||||
> Sources:
|
||||
> - [CQRS](https://martinfowler.com/bliki/CQRS.html) — Martin Fowler
|
||||
> - [Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) — Martin Fowler
|
||||
> - [CQRS Pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs) — Microsoft Azure
|
||||
> - [Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html) — microservices.io
|
||||
> - [Domain Events – Salvation](https://udidahan.com/2009/06/14/domain-events-salvation/) — Udi Dahan
|
||||
> - [Strengthening Your Domain: Domain Events](https://lostechies.com/jimmybogard/2010/04/08/strengthening-your-domain-domain-events/) — Jimmy Bogard
|
||||
> - [Domain Events: Design and Implementation](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation) — Microsoft
|
||||
|
||||
## CQRS Overview
|
||||
|
||||
**Command Query Responsibility Segregation** separates read and write operations into different models.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
API["API Layer"]
|
||||
|
||||
API --> Commands
|
||||
API --> Queries
|
||||
|
||||
subgraph WriteSide["Write Side"]
|
||||
Commands["Commands"]
|
||||
CmdHandler["Command Handler\n(Use Case)"]
|
||||
DomainModel["Domain Model\n(Aggregates)"]
|
||||
WriteDB[("Write Database")]
|
||||
|
||||
Commands --> CmdHandler
|
||||
CmdHandler --> DomainModel
|
||||
DomainModel --> WriteDB
|
||||
end
|
||||
|
||||
subgraph ReadSide["Read Side"]
|
||||
Queries["Queries"]
|
||||
QryHandler["Query Handler\n(Read Model)"]
|
||||
ReadDB[("Read Database\n(Optimized)")]
|
||||
|
||||
Queries --> QryHandler
|
||||
QryHandler --> ReadDB
|
||||
end
|
||||
|
||||
WriteDB -->|Domain Events| EventHandler["Event Handler"]
|
||||
EventHandler -->|Updates| ReadDB
|
||||
|
||||
style WriteSide fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style ReadSide fill:#10b981,stroke:#059669,color:white
|
||||
style EventHandler fill:#f59e0b,stroke:#d97706,color:white
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands vs Queries
|
||||
|
||||
### Commands (Write Side)
|
||||
|
||||
Commands represent intent to change state. They **mutate** data.
|
||||
|
||||
```typescript
|
||||
// application/commands/place_order_command.ts
|
||||
export interface PlaceOrderCommand {
|
||||
type: 'PlaceOrder';
|
||||
customerId: string;
|
||||
items: Array<{
|
||||
productId: string;
|
||||
quantity: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ConfirmOrderCommand {
|
||||
type: 'ConfirmOrder';
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
export interface CancelOrderCommand {
|
||||
type: 'CancelOrder';
|
||||
orderId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class PlaceOrderHandler {
|
||||
async handle(command: PlaceOrderCommand): Promise<OrderId> {
|
||||
const order = Order.create(CustomerId.from(command.customerId));
|
||||
|
||||
for (const item of command.items) {
|
||||
const product = await this.productRepo.findById(item.productId);
|
||||
order.addItem(product.id, item.quantity, product.price);
|
||||
}
|
||||
|
||||
await this.orderRepo.save(order);
|
||||
await this.eventPublisher.publishAll(order.domainEvents);
|
||||
|
||||
return order.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Queries (Read Side)
|
||||
|
||||
Queries retrieve data without side effects. They **never mutate** state.
|
||||
|
||||
```typescript
|
||||
// application/queries/get_order_query.ts
|
||||
export interface GetOrderQuery {
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
export interface GetOrdersByCustomerQuery {
|
||||
customerId: string;
|
||||
status?: OrderStatus;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface OrderDTO {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
status: string;
|
||||
items: Array<{
|
||||
productId: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
subtotal: number;
|
||||
}>;
|
||||
total: number;
|
||||
createdAt: string;
|
||||
confirmedAt?: string;
|
||||
}
|
||||
|
||||
export class GetOrderHandler {
|
||||
constructor(private readonly readDb: IOrderReadModel) {}
|
||||
|
||||
async handle(query: GetOrderQuery): Promise<OrderDTO | null> {
|
||||
return this.readDb.findById(query.orderId);
|
||||
}
|
||||
}
|
||||
|
||||
export class GetOrdersByCustomerHandler {
|
||||
constructor(private readonly readDb: IOrderReadModel) {}
|
||||
|
||||
async handle(query: GetOrdersByCustomerQuery): Promise<PaginatedResult<OrderDTO>> {
|
||||
return this.readDb.findByCustomer(
|
||||
query.customerId,
|
||||
query.status,
|
||||
query.page ?? 1,
|
||||
query.pageSize ?? 20
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Read Model (Projection)
|
||||
|
||||
Optimized database structure for queries. Can denormalize data for performance.
|
||||
|
||||
```
|
||||
interface IOrderReadModel:
|
||||
findById(orderId: string) -> OrderDTO | null
|
||||
findByCustomer(customerId, status?, page?, pageSize?) -> PaginatedResult<OrderDTO>
|
||||
search(criteria: OrderSearchCriteria) -> List<OrderDTO>
|
||||
|
||||
class PostgresOrderReadModel implements IOrderReadModel:
|
||||
db: Database
|
||||
|
||||
findById(orderId: string) -> OrderDTO | null:
|
||||
row = db.ordersRead
|
||||
.where(id: orderId)
|
||||
.join("customer")
|
||||
.withRelated("items.product")
|
||||
.first()
|
||||
|
||||
return row ? this.mapToDTO(row) : null
|
||||
```
|
||||
|
||||
Separate write and read databases (optional): write is normalized for transactions, read is denormalized for queries.
|
||||
|
||||
---
|
||||
|
||||
## Domain Events
|
||||
|
||||
Notifications that something happened in the domain. Used for:
|
||||
- Updating read models
|
||||
- Cross-aggregate communication
|
||||
- Integration with other bounded contexts
|
||||
|
||||
### Event Structure
|
||||
|
||||
```typescript
|
||||
// domain/shared/domain_event.ts
|
||||
export abstract class DomainEvent {
|
||||
readonly eventId: string;
|
||||
readonly occurredAt: Date;
|
||||
readonly aggregateId: string;
|
||||
abstract readonly eventType: string;
|
||||
|
||||
constructor(aggregateId: string) {
|
||||
this.eventId = crypto.randomUUID();
|
||||
this.occurredAt = new Date();
|
||||
this.aggregateId = aggregateId;
|
||||
}
|
||||
|
||||
abstract toPayload(): Record<string, unknown>;
|
||||
}
|
||||
|
||||
// domain/order/events.ts
|
||||
export class OrderCreated extends DomainEvent {
|
||||
readonly eventType = 'order.created';
|
||||
|
||||
constructor(
|
||||
readonly orderId: OrderId,
|
||||
readonly customerId: CustomerId,
|
||||
) {
|
||||
super(orderId.value);
|
||||
}
|
||||
|
||||
toPayload() {
|
||||
return {
|
||||
orderId: this.orderId.value,
|
||||
customerId: this.customerId.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class OrderConfirmed extends DomainEvent {
|
||||
readonly eventType = 'order.confirmed';
|
||||
|
||||
constructor(
|
||||
readonly orderId: OrderId,
|
||||
readonly total: Money,
|
||||
readonly items: ReadonlyArray<{ productId: string; quantity: number }>,
|
||||
) {
|
||||
super(orderId.value);
|
||||
}
|
||||
|
||||
toPayload() {
|
||||
return {
|
||||
orderId: this.orderId.value,
|
||||
total: { amount: this.total.amount, currency: this.total.currency },
|
||||
items: this.items,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class OrderShipped extends DomainEvent {
|
||||
readonly eventType = 'order.shipped';
|
||||
|
||||
constructor(
|
||||
readonly orderId: OrderId,
|
||||
readonly trackingNumber: string,
|
||||
readonly carrier: string,
|
||||
) {
|
||||
super(orderId.value);
|
||||
}
|
||||
|
||||
toPayload() {
|
||||
return {
|
||||
orderId: this.orderId.value,
|
||||
trackingNumber: this.trackingNumber,
|
||||
carrier: this.carrier,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Handlers
|
||||
|
||||
```
|
||||
class OrderCreatedHandler:
|
||||
db: Database
|
||||
|
||||
handle(event: OrderCreated):
|
||||
db.ordersRead.insert({
|
||||
id: event.orderId.value,
|
||||
customerId: event.customerId.value,
|
||||
status: "draft",
|
||||
createdAt: event.occurredAt
|
||||
})
|
||||
|
||||
class OrderConfirmedHandler:
|
||||
db: Database
|
||||
|
||||
handle(event: OrderConfirmed):
|
||||
db.ordersRead
|
||||
.where(id: event.orderId.value)
|
||||
.update({
|
||||
status: "confirmed",
|
||||
total: event.total.amount,
|
||||
confirmedAt: event.occurredAt
|
||||
})
|
||||
|
||||
export class SendShippingNotificationHandler {
|
||||
constructor(
|
||||
private readonly orderRepo: IOrderRepository,
|
||||
private readonly notifier: INotificationService,
|
||||
) {}
|
||||
|
||||
async handle(event: OrderShipped): Promise<void> {
|
||||
const order = await this.orderRepo.findById(OrderId.from(event.orderId.value));
|
||||
if (!order) return;
|
||||
|
||||
await this.notifier.sendEmail(order.customerEmail, {
|
||||
template: 'order-shipped',
|
||||
data: {
|
||||
orderId: event.orderId.value,
|
||||
trackingNumber: event.trackingNumber,
|
||||
carrier: event.carrier,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Events vs Integration Events
|
||||
|
||||
### Domain Events
|
||||
|
||||
- Stay within bounded context
|
||||
- Fine-grained, low-level
|
||||
- Trigger internal processes
|
||||
- Named in domain language
|
||||
|
||||
```typescript
|
||||
class OrderItemQuantityIncreased extends DomainEvent {
|
||||
constructor(
|
||||
readonly orderId: OrderId,
|
||||
readonly productId: ProductId,
|
||||
readonly oldQuantity: number,
|
||||
readonly newQuantity: number,
|
||||
) { super(orderId.value); }
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Events
|
||||
|
||||
- Cross bounded context boundaries
|
||||
- Coarser-grained
|
||||
- Published to message broker
|
||||
- Versioned schema
|
||||
|
||||
```typescript
|
||||
interface OrderConfirmedIntegrationEvent {
|
||||
eventType: 'sales.order.confirmed';
|
||||
eventId: string;
|
||||
version: '1.0';
|
||||
occurredAt: string;
|
||||
payload: {
|
||||
orderId: string;
|
||||
customerId: string;
|
||||
total: { amount: number; currency: string };
|
||||
items: Array<{
|
||||
productId: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}>;
|
||||
shippingAddress: {
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Publishing Integration Events
|
||||
|
||||
```typescript
|
||||
// application/event_handlers/publish_integration_events.ts
|
||||
export class PublishOrderConfirmedIntegrationEvent {
|
||||
constructor(
|
||||
private readonly messageBroker: IMessageBroker,
|
||||
private readonly orderRepo: IOrderRepository,
|
||||
) {}
|
||||
|
||||
async handle(domainEvent: OrderConfirmed): Promise<void> {
|
||||
const order = await this.orderRepo.findById(domainEvent.orderId);
|
||||
if (!order) return;
|
||||
|
||||
const integrationEvent: OrderConfirmedIntegrationEvent = {
|
||||
eventType: 'sales.order.confirmed',
|
||||
eventId: crypto.randomUUID(),
|
||||
version: '1.0',
|
||||
occurredAt: new Date().toISOString(),
|
||||
payload: {
|
||||
orderId: order.id.value,
|
||||
customerId: order.customerId.value,
|
||||
total: {
|
||||
amount: order.total.amount,
|
||||
currency: order.total.currency,
|
||||
},
|
||||
items: order.items.map(item => ({
|
||||
productId: item.productId.value,
|
||||
quantity: item.quantity.value,
|
||||
unitPrice: item.unitPrice.amount,
|
||||
})),
|
||||
shippingAddress: order.shippingAddress
|
||||
? {
|
||||
street: order.shippingAddress.street,
|
||||
city: order.shippingAddress.city,
|
||||
postalCode: order.shippingAddress.postalCode,
|
||||
country: order.shippingAddress.country,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
|
||||
await this.messageBroker.publish('order-events', integrationEvent);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Dispatcher Pattern
|
||||
|
||||
```typescript
|
||||
// infrastructure/events/event_dispatcher.ts
|
||||
export interface IEventHandler<T extends DomainEvent> {
|
||||
handle(event: T): Promise<void>;
|
||||
}
|
||||
|
||||
export class EventDispatcher {
|
||||
private handlers: Map<string, IEventHandler<any>[]> = new Map();
|
||||
|
||||
register<T extends DomainEvent>(
|
||||
eventType: string,
|
||||
handler: IEventHandler<T>,
|
||||
): void {
|
||||
const existing = this.handlers.get(eventType) ?? [];
|
||||
existing.push(handler);
|
||||
this.handlers.set(eventType, existing);
|
||||
}
|
||||
|
||||
async dispatch(event: DomainEvent): Promise<void> {
|
||||
const handlers = this.handlers.get(event.eventType) ?? [];
|
||||
await Promise.all(handlers.map(h => h.handle(event)));
|
||||
}
|
||||
|
||||
async dispatchAll(events: DomainEvent[]): Promise<void> {
|
||||
for (const event of events) {
|
||||
await this.dispatch(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dispatcher = new EventDispatcher();
|
||||
dispatcher.register('order.created', new OrderCreatedHandler(readDb));
|
||||
dispatcher.register('order.confirmed', new OrderConfirmedHandler(readDb));
|
||||
dispatcher.register('order.confirmed', new PublishOrderConfirmedIntegrationEvent(broker, orderRepo));
|
||||
dispatcher.register('order.shipped', new SendShippingNotificationHandler(orderRepo, notifier));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Outbox Pattern
|
||||
|
||||
Ensures events are published reliably (exactly-once semantics).
|
||||
|
||||
```
|
||||
interface OutboxMessage:
|
||||
id: string
|
||||
eventType: string
|
||||
payload: string
|
||||
createdAt: DateTime
|
||||
processedAt: DateTime | null
|
||||
|
||||
class OutboxRepository:
|
||||
db: Database
|
||||
|
||||
save(event: DomainEvent, tx: Transaction):
|
||||
tx.outbox.insert({
|
||||
id: event.eventId,
|
||||
eventType: event.eventType,
|
||||
payload: serialize(event.toPayload()),
|
||||
createdAt: event.occurredAt
|
||||
})
|
||||
|
||||
getUnprocessed(limit: int = 100) -> List<OutboxMessage>:
|
||||
return db.outbox
|
||||
.where(processedAt: null)
|
||||
.orderBy("createdAt")
|
||||
.limit(limit)
|
||||
.lockForUpdate()
|
||||
|
||||
markProcessed(id: string):
|
||||
db.outbox.where(id: id).update({processedAt: now()})
|
||||
|
||||
class PlaceOrderHandler:
|
||||
orderRepo: IOrderRepository
|
||||
outbox: OutboxRepository
|
||||
db: Database
|
||||
|
||||
handle(command: PlaceOrderCommand) -> OrderId:
|
||||
order = Order.create(CustomerId.from(command.customerId))
|
||||
|
||||
db.transaction((tx) => {
|
||||
orderRepo.save(order, tx)
|
||||
for event in order.domainEvents:
|
||||
outbox.save(event, tx)
|
||||
})
|
||||
|
||||
return order.id
|
||||
|
||||
class OutboxProcessor:
|
||||
outbox: OutboxRepository
|
||||
messageBroker: IMessageBroker
|
||||
|
||||
process():
|
||||
messages = outbox.getUnprocessed()
|
||||
|
||||
for message in messages:
|
||||
try:
|
||||
messageBroker.publish(message.eventType, message.payload)
|
||||
outbox.markProcessed(message.id)
|
||||
catch error:
|
||||
log.error("Failed to process outbox message", message.id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to Use CQRS
|
||||
|
||||
> **Warning:** "You should be very cautious about using CQRS... the majority of cases I've run into have not been so good." — Martin Fowler
|
||||
|
||||
CQRS adds significant complexity. Most applications don't need it.
|
||||
|
||||
### Use CQRS When:
|
||||
|
||||
- Read and write workloads have **dramatically** different scaling requirements
|
||||
- Complex queries that genuinely don't map well to domain model
|
||||
- Different teams work on read vs write sides
|
||||
- Event sourcing is used (CQRS pairs naturally with ES)
|
||||
- You've proven simpler approaches are insufficient
|
||||
|
||||
### Skip CQRS When:
|
||||
|
||||
- Simple CRUD application (most applications)
|
||||
- Read/write patterns are similar
|
||||
- Small team, simple domain
|
||||
- You haven't tried a simple reporting database first
|
||||
- Adding it "just in case"
|
||||
|
||||
**CQRS applies to specific bounded contexts, never entire systems.**
|
||||
|
||||
### Simplified CQRS (Start Here)
|
||||
|
||||
Start simple—same database, different query paths:
|
||||
|
||||
```typescript
|
||||
class OrderService {
|
||||
async placeOrder(cmd: PlaceOrderCommand): Promise<OrderId> {
|
||||
const order = Order.create(...);
|
||||
await this.orderRepo.save(order);
|
||||
return order.id;
|
||||
}
|
||||
|
||||
async getOrder(id: string): Promise<OrderDTO | null> {
|
||||
return this.readModel.findById(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Evolve to separate databases only when needed.
|
||||
|
||||
---
|
||||
|
||||
## Event Sourcing: Critical Considerations
|
||||
|
||||
> **Warning:** "Extremely difficult to add Event Sourcing to systems not originally designed for it." — Martin Fowler
|
||||
|
||||
### When Event Sourcing Makes Sense
|
||||
|
||||
- Complete audit trail is a business requirement
|
||||
- Need to reconstruct state at any point in time
|
||||
- Domain is inherently event-driven (financial transactions, workflows)
|
||||
- Debugging requires understanding "how did we get here?"
|
||||
|
||||
### When to Avoid Event Sourcing
|
||||
|
||||
- Simple CRUD with no audit requirements
|
||||
- Team unfamiliar with event-driven patterns
|
||||
- Adding it retroactively to existing system
|
||||
- No clear business need for temporal queries
|
||||
|
||||
### Event Sourcing Requirements
|
||||
|
||||
1. **Events must store deltas** — Not final state, but what changed (enables reversal)
|
||||
2. **Snapshots for performance** — Rebuild from snapshots, not from event 0
|
||||
3. **External system handling:**
|
||||
- Disable notifications during replays
|
||||
- Cache external query results with timestamps
|
||||
4. **Schema evolution strategy** — Events are forever; plan for versioning
|
||||
|
||||
---
|
||||
|
||||
## Saga Pattern (Cross-Aggregate Workflows)
|
||||
|
||||
For workflows spanning multiple aggregates, use sagas instead of trying to coordinate via raw domain events.
|
||||
|
||||
```
|
||||
Saga: PlaceOrderSaga
|
||||
├── Step 1: Reserve inventory (Inventory aggregate)
|
||||
├── Step 2: Process payment (Payment aggregate)
|
||||
├── Step 3: Confirm order (Order aggregate)
|
||||
└── Compensating actions if any step fails
|
||||
```
|
||||
|
||||
**Saga types:**
|
||||
- **Choreography:** Each service listens/publishes events (simpler, harder to trace)
|
||||
- **Orchestration:** Central coordinator manages steps (explicit, easier to debug)
|
||||
|
||||
---
|
||||
|
||||
## Idempotent Consumer Pattern
|
||||
|
||||
**Required for reliable event processing.** Messages may be delivered more than once.
|
||||
|
||||
```
|
||||
class OrderConfirmedHandler:
|
||||
processedIds: Set<string>
|
||||
|
||||
handle(event: OrderConfirmed):
|
||||
if event.eventId in processedIds:
|
||||
return
|
||||
|
||||
doWork(event)
|
||||
processedIds.add(event.eventId)
|
||||
```
|
||||
|
||||
**Implementation options:**
|
||||
- Store processed message IDs in database
|
||||
- Use message broker's deduplication features
|
||||
- Design handlers to be naturally idempotent
|
||||
495
.agents/skills/clean-ddd-hexagonal/references/DDD-STRATEGIC.md
Normal file
495
.agents/skills/clean-ddd-hexagonal/references/DDD-STRATEGIC.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# DDD Strategic Patterns
|
||||
|
||||
> Sources:
|
||||
> - [Domain-Driven Design: The Blue Book](https://www.domainlanguage.com/ddd/blue-book/) — Eric Evans (2003)
|
||||
> - [DDD Resources](https://www.domainlanguage.com/ddd/) — Domain Language (Eric Evans)
|
||||
> - [Bounded Context](https://martinfowler.com/bliki/BoundedContext.html) — Martin Fowler
|
||||
> - [Domain Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) — Martin Fowler
|
||||
> - [Anti-Corruption Layer](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/acl.html) — AWS
|
||||
> - [Domain Analysis for Microservices](https://learn.microsoft.com/en-us/azure/architecture/microservices/model/domain-analysis) — Microsoft
|
||||
|
||||
## Overview
|
||||
|
||||
Strategic DDD patterns help decompose large systems into manageable parts with clear boundaries. They answer: **"How do we divide a complex domain?"**
|
||||
|
||||
**DDD is fundamentally collaborative.** The patterns below emerge from conversations, whiteboarding, and modeling sessions with domain experts—not from coding alone.
|
||||
|
||||
---
|
||||
|
||||
## Domain Discovery Techniques
|
||||
|
||||
### Event Storming
|
||||
|
||||
A workshop technique for discovering domain events, aggregates, and bounded contexts.
|
||||
|
||||
```
|
||||
Orange sticky: Domain Event (past tense: "OrderPlaced")
|
||||
Blue sticky: Command (imperative: "Place Order")
|
||||
Yellow sticky: Aggregate (noun: "Order")
|
||||
Pink sticky: External System / Policy
|
||||
Purple sticky: Problem / Question
|
||||
```
|
||||
|
||||
**Workshop flow:**
|
||||
1. **Chaotic exploration** — Everyone adds events they know about
|
||||
2. **Timeline ordering** — Arrange events chronologically
|
||||
3. **Identify aggregates** — Group related events
|
||||
4. **Find boundaries** — Where language changes = bounded context boundary
|
||||
5. **Surface problems** — Mark unclear areas for follow-up
|
||||
|
||||
### Context Mapping Workshop
|
||||
|
||||
For existing systems, map how bounded contexts currently interact:
|
||||
1. List all systems/services
|
||||
2. Identify which team owns each
|
||||
3. Draw relationships (upstream/downstream)
|
||||
4. Label relationship types (ACL, Conformist, etc.)
|
||||
5. Identify pain points in current integrations
|
||||
|
||||
---
|
||||
|
||||
## Ubiquitous Language
|
||||
|
||||
The foundation of DDD. A shared vocabulary between developers and domain experts that appears in:
|
||||
- Code (class names, method names)
|
||||
- Documentation
|
||||
- Conversations
|
||||
- UI labels
|
||||
|
||||
### Principles
|
||||
|
||||
1. **One language per bounded context** - Different contexts may use the same word differently
|
||||
2. **Code reflects the language** - `Order.confirm()` not `Order.setStatus("confirmed")`
|
||||
3. **Evolve together** - When language changes, code changes
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
❌ Technical language:
|
||||
"Set the order entity's status field to 2 and insert a record"
|
||||
|
||||
✅ Ubiquitous language:
|
||||
"Confirm the order and record that it was confirmed"
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ Technical, not ubiquitous
|
||||
class Order {
|
||||
setStatus(status: number): void { this.status = status; }
|
||||
}
|
||||
|
||||
// ✅ Ubiquitous language
|
||||
class Order {
|
||||
confirm(): void {
|
||||
if (this.status !== OrderStatus.Pending) {
|
||||
throw new OrderCannotBeConfirmedException(this.id);
|
||||
}
|
||||
this.status = OrderStatus.Confirmed;
|
||||
this.confirmedAt = new Date();
|
||||
this.addDomainEvent(new OrderConfirmed(this.id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bounded Contexts
|
||||
|
||||
A **semantic boundary** where a particular domain model applies. Within a bounded context, terms have precise, unambiguous meaning.
|
||||
|
||||
> **Key insight:** Polysemy (same word, different meanings) across departments is natural, not a problem. The same term meaning different things in different contexts is expected—"the dominant boundary factor is human culture and language variation." — Martin Fowler
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- Each bounded context has its **own ubiquitous language**
|
||||
- Each bounded context has its **own model**
|
||||
- The same real-world concept may have **different representations** in different contexts
|
||||
|
||||
### Example: E-Commerce System
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph ECommerce["E-Commerce System"]
|
||||
subgraph Sales["Sales Context"]
|
||||
SC1["Customer: id, email, preferences"]
|
||||
SC2["Order: items, total, status"]
|
||||
end
|
||||
subgraph Shipping["Shipping Context"]
|
||||
SH1["Recipient: name, address, phone"]
|
||||
SH2["Shipment: packages, carrier, trackingNo"]
|
||||
end
|
||||
subgraph Billing["Billing Context"]
|
||||
BC1["Payer: name, billingAddress, paymentMethod"]
|
||||
BC2["Invoice: lineItems, total, dueDate"]
|
||||
end
|
||||
subgraph Catalog["Catalog Context"]
|
||||
CC1["Product: name, description, price"]
|
||||
CC2["(no customer concept)"]
|
||||
end
|
||||
end
|
||||
|
||||
style Sales fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style Shipping fill:#10b981,stroke:#059669,color:white
|
||||
style Billing fill:#f59e0b,stroke:#d97706,color:white
|
||||
style Catalog fill:#8b5cf6,stroke:#7c3aed,color:white
|
||||
```
|
||||
|
||||
**"Customer" means different things:**
|
||||
- **Sales**: Email, preferences, order history
|
||||
- **Shipping**: Delivery address, phone number
|
||||
- **Billing**: Payment methods, billing address
|
||||
|
||||
### Bounded Context = Microservice Boundary
|
||||
|
||||
In microservices, each bounded context typically becomes a separate service:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Sales["Sales Service"]
|
||||
S1["Orders DB"]
|
||||
S2["Order API"]
|
||||
end
|
||||
subgraph Shipping["Shipping Service"]
|
||||
SH1["Shipments DB"]
|
||||
SH2["Shipping API"]
|
||||
end
|
||||
subgraph Billing["Billing Service"]
|
||||
B1["Invoices DB"]
|
||||
B2["Billing API"]
|
||||
end
|
||||
|
||||
Sales -->|events| Shipping
|
||||
Shipping -->|events| Billing
|
||||
Sales -.->|Integration Events| Events[("Event Bus")]
|
||||
Shipping -.-> Events
|
||||
Billing -.-> Events
|
||||
|
||||
style Sales fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style Shipping fill:#10b981,stroke:#059669,color:white
|
||||
style Billing fill:#f59e0b,stroke:#d97706,color:white
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Subdomains
|
||||
|
||||
Areas of business expertise. Subdomains are **discovered**, not designed.
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Description | Investment | Example |
|
||||
|------|-------------|------------|---------|
|
||||
| **Core** | Competitive advantage | High | Product recommendation engine |
|
||||
| **Supporting** | Necessary but not unique | Medium | Order management |
|
||||
| **Generic** | Commodity, buy/outsource | Low | Email sending, payments |
|
||||
|
||||
### Identification Questions
|
||||
|
||||
1. What makes us different from competitors? → **Core**
|
||||
2. What do we need but isn't our specialty? → **Supporting**
|
||||
3. What does everyone need the same way? → **Generic**
|
||||
|
||||
### Example: E-Commerce
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Subdomains["Subdomains"]
|
||||
subgraph Core["CORE"]
|
||||
C1["Product search & recommendations"]
|
||||
C2["Pricing engine"]
|
||||
C3["Personalization"]
|
||||
end
|
||||
subgraph Supporting["SUPPORTING"]
|
||||
S1["Order management"]
|
||||
S2["Inventory"]
|
||||
S3["Customer support"]
|
||||
S4["Reporting"]
|
||||
end
|
||||
subgraph Generic["GENERIC"]
|
||||
G1["Authentication (Auth0)"]
|
||||
G2["Payments (Stripe)"]
|
||||
G3["Email (SendGrid)"]
|
||||
G4["File storage (S3)"]
|
||||
end
|
||||
end
|
||||
|
||||
Core --> CoreStrat["Build in-house\nBest developers"]
|
||||
Supporting --> SuppStrat["Build or buy\nSolid but simple"]
|
||||
Generic --> GenStrat["Use third-party\nDon't reinvent"]
|
||||
|
||||
style Core fill:#ef4444,stroke:#dc2626,color:white
|
||||
style Supporting fill:#f59e0b,stroke:#d97706,color:white
|
||||
style Generic fill:#6b7280,stroke:#4b5563,color:white
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Mapping
|
||||
|
||||
Describes relationships between bounded contexts.
|
||||
|
||||
### Relationship Patterns
|
||||
|
||||
#### Partnership
|
||||
Two contexts succeed or fail together. Teams coordinate closely.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Context A"] <-->|"Partnership\nJoint planning\nShared success"| B["Context B"]
|
||||
|
||||
style A fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style B fill:#3b82f6,stroke:#2563eb,color:white
|
||||
```
|
||||
|
||||
#### Shared Kernel
|
||||
Two contexts share a subset of the domain model.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph A["Context A"]
|
||||
SK["Shared Kernel"]
|
||||
end
|
||||
subgraph B["Context B"]
|
||||
B1[" "]
|
||||
end
|
||||
|
||||
SK <-->|shared| B
|
||||
|
||||
style A fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style B fill:#10b981,stroke:#059669,color:white
|
||||
style SK fill:#f59e0b,stroke:#d97706,color:white
|
||||
```
|
||||
|
||||
**Warning:** Shared kernels create coupling. Use sparingly.
|
||||
|
||||
#### Customer-Supplier
|
||||
Upstream context provides what downstream needs.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["Upstream\n(Supplier)"] -->|"Provides API"| D["Downstream\n(Customer)"]
|
||||
|
||||
style U fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style D fill:#10b981,stroke:#059669,color:white
|
||||
```
|
||||
|
||||
#### Conformist
|
||||
Downstream conforms to upstream's model with no negotiation power.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["Upstream\n(Dictator)"] -->|"Take it or leave it"| D["Downstream\n(Conformist)\nUses their model"]
|
||||
|
||||
style U fill:#ef4444,stroke:#dc2626,color:white
|
||||
style D fill:#6b7280,stroke:#4b5563,color:white
|
||||
```
|
||||
|
||||
**Example:** Integrating with a third-party API (Stripe, AWS).
|
||||
|
||||
#### Anti-Corruption Layer (ACL)
|
||||
Translation layer protecting your model from external models.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Ext["External\nContext"] --> ACL["ACL\nTranslator + Adapter"]
|
||||
ACL --> Your["Your\nContext"]
|
||||
|
||||
ACL -.->|"Translates external\nmodel to your model"| Note[" "]
|
||||
|
||||
style Ext fill:#ef4444,stroke:#dc2626,color:white
|
||||
style ACL fill:#f59e0b,stroke:#d97706,color:white
|
||||
style Your fill:#10b981,stroke:#059669,color:white
|
||||
style Note fill:none,stroke:none
|
||||
```
|
||||
|
||||
**Use when:**
|
||||
- Integrating with legacy systems
|
||||
- Integrating with third-party APIs
|
||||
- External model is messy or poorly designed
|
||||
|
||||
```typescript
|
||||
// Anti-Corruption Layer Example
|
||||
// infrastructure/external/stripe/stripe_payment_acl.ts
|
||||
|
||||
import Stripe from 'stripe';
|
||||
import { Payment, PaymentStatus } from '@/domain/payment/payment';
|
||||
import { Money } from '@/domain/shared/money';
|
||||
|
||||
export class StripePaymentACL {
|
||||
constructor(private readonly stripe: Stripe) {}
|
||||
|
||||
async createPayment(payment: Payment): Promise<string> {
|
||||
const paymentIntent = await this.stripe.paymentIntents.create({
|
||||
amount: payment.amount.cents,
|
||||
currency: payment.amount.currency.toLowerCase(),
|
||||
metadata: {
|
||||
orderId: payment.orderId.value,
|
||||
customerId: payment.customerId.value,
|
||||
},
|
||||
});
|
||||
|
||||
return paymentIntent.id;
|
||||
}
|
||||
|
||||
translateStatus(stripeStatus: string): PaymentStatus {
|
||||
const mapping: Record<string, PaymentStatus> = {
|
||||
'requires_payment_method': PaymentStatus.Pending,
|
||||
'requires_confirmation': PaymentStatus.Pending,
|
||||
'requires_action': PaymentStatus.Pending,
|
||||
'processing': PaymentStatus.Processing,
|
||||
'succeeded': PaymentStatus.Completed,
|
||||
'canceled': PaymentStatus.Cancelled,
|
||||
'requires_capture': PaymentStatus.Authorized,
|
||||
};
|
||||
|
||||
return mapping[stripeStatus] ?? PaymentStatus.Unknown;
|
||||
}
|
||||
|
||||
translateWebhook(event: Stripe.Event): DomainEvent | null {
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
const intent = event.data.object as Stripe.PaymentIntent;
|
||||
return new PaymentCompleted(
|
||||
PaymentId.from(intent.metadata.orderId),
|
||||
Money.fromCents(intent.amount, intent.currency.toUpperCase())
|
||||
);
|
||||
case 'payment_intent.payment_failed':
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Open Host Service / Published Language
|
||||
Expose a well-defined protocol for integration.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph OHS["Open Host Service"]
|
||||
PL["Published Language\n(REST API, gRPC, Events Schema)"]
|
||||
BC["Your Bounded Context"]
|
||||
end
|
||||
|
||||
PL --> A["Consumer A"]
|
||||
PL --> B["Consumer B"]
|
||||
PL --> C["Consumer C"]
|
||||
|
||||
style OHS fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style PL fill:#10b981,stroke:#059669,color:white
|
||||
style A fill:#6b7280,stroke:#4b5563,color:white
|
||||
style B fill:#6b7280,stroke:#4b5563,color:white
|
||||
style C fill:#6b7280,stroke:#4b5563,color:white
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Map Diagram
|
||||
|
||||
Visual representation of all bounded contexts and their relationships:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Identity["Identity Context\n(Generic - Auth0)"]
|
||||
Legacy["Legacy Catalog\n(Legacy)"]
|
||||
Sales["Sales Context\n(Core)"]
|
||||
Shipping["Shipping Context\n(Supporting)"]
|
||||
Billing["Billing Context\n(Supporting)"]
|
||||
Stripe["Stripe Gateway\n(Generic)"]
|
||||
|
||||
Identity -->|Conformist| Sales
|
||||
Legacy -->|ACL| Sales
|
||||
Sales <-->|Customer-Supplier| Shipping
|
||||
Sales -->|Open Host Service| Billing
|
||||
Billing -->|Conformist| Stripe
|
||||
|
||||
style Identity fill:#6b7280,stroke:#4b5563,color:white
|
||||
style Legacy fill:#9ca3af,stroke:#6b7280,color:white
|
||||
style Sales fill:#ef4444,stroke:#dc2626,color:white
|
||||
style Shipping fill:#f59e0b,stroke:#d97706,color:white
|
||||
style Billing fill:#f59e0b,stroke:#d97706,color:white
|
||||
style Stripe fill:#6b7280,stroke:#4b5563,color:white
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### Domain Events for Context Integration
|
||||
|
||||
```typescript
|
||||
interface OrderPlaced {
|
||||
eventType: 'sales.order.placed';
|
||||
orderId: string;
|
||||
customerId: string;
|
||||
items: Array<{ productId: string; quantity: number; price: number }>;
|
||||
total: number;
|
||||
shippingAddress: Address;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
class ShippingOrderPlacedHandler {
|
||||
async handle(event: OrderPlaced): Promise<void> {
|
||||
const shipment = Shipment.create({
|
||||
orderId: ShipmentOrderId.from(event.orderId),
|
||||
recipient: Recipient.fromAddress(event.shippingAddress),
|
||||
packages: this.calculatePackages(event.items),
|
||||
});
|
||||
|
||||
await this.shipmentRepository.save(shipment);
|
||||
}
|
||||
}
|
||||
|
||||
class BillingOrderPlacedHandler {
|
||||
async handle(event: OrderPlaced): Promise<void> {
|
||||
const invoice = Invoice.create({
|
||||
orderId: InvoiceOrderId.from(event.orderId),
|
||||
customerId: BillingCustomerId.from(event.customerId),
|
||||
lineItems: event.items.map(item => ({
|
||||
description: `Product ${item.productId}`,
|
||||
quantity: item.quantity,
|
||||
unitPrice: Money.fromNumber(item.price),
|
||||
})),
|
||||
total: Money.fromNumber(event.total),
|
||||
});
|
||||
|
||||
await this.invoiceRepository.save(invoice);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Schema Registry
|
||||
|
||||
Define and version integration event schemas:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://api.company.com/events/sales/order-placed/v1.json",
|
||||
"title": "OrderPlaced",
|
||||
"description": "Published when an order is successfully placed",
|
||||
"type": "object",
|
||||
"required": ["eventType", "eventId", "orderId", "occurredAt"],
|
||||
"properties": {
|
||||
"eventType": { "const": "sales.order.placed" },
|
||||
"eventId": { "type": "string", "format": "uuid" },
|
||||
"orderId": { "type": "string", "format": "uuid" },
|
||||
"customerId": { "type": "string", "format": "uuid" },
|
||||
"total": { "type": "number", "minimum": 0 },
|
||||
"occurredAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Strategic Design Checklist
|
||||
|
||||
- [ ] Identify ubiquitous language terms with domain experts
|
||||
- [ ] Map subdomains (core, supporting, generic)
|
||||
- [ ] Define bounded context boundaries
|
||||
- [ ] Document context map with relationships
|
||||
- [ ] Design anti-corruption layers for external systems
|
||||
- [ ] Define integration event schemas
|
||||
- [ ] Ensure each context has its own data store
|
||||
522
.agents/skills/clean-ddd-hexagonal/references/DDD-TACTICAL.md
Normal file
522
.agents/skills/clean-ddd-hexagonal/references/DDD-TACTICAL.md
Normal file
@@ -0,0 +1,522 @@
|
||||
# DDD Tactical Patterns
|
||||
|
||||
> Sources:
|
||||
> - [Domain-Driven Design: The Blue Book](https://www.domainlanguage.com/ddd/blue-book/) — Eric Evans (2003)
|
||||
> - [Implementing Domain-Driven Design](https://openlibrary.org/works/OL17392277W) — Vaughn Vernon (2013)
|
||||
> - [Effective Aggregate Design](https://www.dddcommunity.org/library/vernon_2011/) — Vaughn Vernon
|
||||
> - [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) — Martin Fowler (PoEAA)
|
||||
|
||||
## Building Blocks Overview
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Aggregate["Aggregate"]
|
||||
subgraph AggRoot["Aggregate Root (Entity)"]
|
||||
E1["Entity"]
|
||||
E2["Entity"]
|
||||
VO1["Value Object"]
|
||||
VO2["Value Object"]
|
||||
DE["Domain Event"]
|
||||
end
|
||||
end
|
||||
|
||||
Aggregate -->|Repository| Persistence[("Persistence")]
|
||||
|
||||
style Aggregate fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style AggRoot fill:#10b981,stroke:#059669,color:white
|
||||
style Persistence fill:#6b7280,stroke:#4b5563,color:white
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entity
|
||||
|
||||
An object with **identity** that persists through time. Two entities are equal if they have the same identity, regardless of attribute values.
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Has a unique identifier
|
||||
- Identity persists through lifecycle
|
||||
- Can change attributes but remains the same entity
|
||||
- Contains behavior (not just data)
|
||||
|
||||
### Pattern
|
||||
|
||||
```
|
||||
abstract class Entity<ID>:
|
||||
id: ID
|
||||
|
||||
equals(other: Entity<ID>) -> bool:
|
||||
return this.id == other.id
|
||||
|
||||
class OrderItem extends Entity<OrderItemId>:
|
||||
productId: ProductId
|
||||
quantity: Quantity
|
||||
unitPrice: Money
|
||||
|
||||
static create(productId, quantity, unitPrice) -> OrderItem:
|
||||
return new OrderItem(
|
||||
id: OrderItemId.generate(),
|
||||
productId: productId,
|
||||
quantity: quantity,
|
||||
unitPrice: unitPrice
|
||||
)
|
||||
|
||||
increaseQuantity(amount: int):
|
||||
this.quantity = this.quantity.add(amount)
|
||||
|
||||
subtotal() -> Money:
|
||||
return this.unitPrice.multiply(this.quantity.value)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Value Object
|
||||
|
||||
An object defined by its **attributes**, not identity. Two value objects are equal if all their attributes are equal.
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Immutable (no setters)
|
||||
- No identity
|
||||
- Equality by value (all attributes)
|
||||
- Self-validating
|
||||
- Side-effect-free methods
|
||||
|
||||
### Common Value Objects
|
||||
|
||||
| Value Object | Attributes | Validation |
|
||||
|--------------|-----------|------------|
|
||||
| Money | amount, currency | amount >= 0 |
|
||||
| Email | address | valid email format |
|
||||
| Address | street, city, zip, country | required fields |
|
||||
| DateRange | start, end | start <= end |
|
||||
| Quantity | value | value > 0 |
|
||||
|
||||
### Pattern
|
||||
|
||||
```
|
||||
abstract class ValueObject<Props>:
|
||||
props: Props
|
||||
|
||||
equals(other: ValueObject<Props>) -> bool:
|
||||
return deepEqual(this.props, other.props)
|
||||
|
||||
class Money extends ValueObject<{amount, currency}>:
|
||||
|
||||
static create(amount, currency) -> Money:
|
||||
guard: amount >= 0
|
||||
guard: currency in SUPPORTED_CURRENCIES
|
||||
return new Money({amount, currency})
|
||||
|
||||
static zero(currency = "USD") -> Money:
|
||||
return Money.create(0, currency)
|
||||
|
||||
add(other: Money) -> Money:
|
||||
guard: this.currency == other.currency
|
||||
return Money.create(this.amount + other.amount, this.currency)
|
||||
|
||||
subtract(other: Money) -> Money:
|
||||
guard: this.currency == other.currency
|
||||
return Money.create(this.amount - other.amount, this.currency)
|
||||
|
||||
multiply(factor: number) -> Money:
|
||||
return Money.create(this.amount * factor, this.currency)
|
||||
|
||||
class Email extends ValueObject<{value}>:
|
||||
|
||||
static create(email: string) -> Email:
|
||||
normalized = email.lowercase().trim()
|
||||
guard: isValidEmailFormat(normalized)
|
||||
return new Email({value: normalized})
|
||||
|
||||
domain() -> string:
|
||||
return this.value.split("@")[1]
|
||||
|
||||
class OrderId extends ValueObject<{value}>:
|
||||
|
||||
static generate() -> OrderId:
|
||||
return new OrderId({value: generateUUID()})
|
||||
|
||||
static from(value: string) -> OrderId:
|
||||
guard: value is not empty
|
||||
return new OrderId({value})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Aggregate
|
||||
|
||||
A cluster of entities and value objects treated as a single unit for data changes. Has a **consistency boundary**.
|
||||
|
||||
### Rules
|
||||
|
||||
1. **One aggregate root** - Single entry point for all modifications
|
||||
2. **Reference by ID only** - Aggregates reference others by identity, never by direct object reference
|
||||
3. **Transaction boundary** - One aggregate per transaction (eventual consistency between aggregates)
|
||||
4. **Invariants within boundary** - Aggregate ensures its own consistency
|
||||
5. **Small aggregates** - Prefer smaller over larger
|
||||
|
||||
### Aggregate Sizing Heuristics
|
||||
|
||||
| Metric | Healthy | Warning | Action |
|
||||
|--------|---------|---------|--------|
|
||||
| Entities per aggregate | 1-5 | 6-10 | >10: Split |
|
||||
| Lines of code (root) | <500 | 500-1000 | >1000: Split |
|
||||
| Transaction lock time | <100ms | 100-500ms | >500ms: Split |
|
||||
| Concurrent modification conflicts | Rare | Occasional | Frequent: Split |
|
||||
|
||||
**Questions to ask:**
|
||||
- Can parts be eventually consistent? → Separate aggregates
|
||||
- Do all parts change together? → Same aggregate
|
||||
- Are there independent lifecycles? → Separate aggregates
|
||||
|
||||
### Design Guidelines
|
||||
|
||||
**Good: Small Aggregates**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Order["Order Aggregate"]
|
||||
O["Order"]
|
||||
OI["OrderItems (embedded)"]
|
||||
end
|
||||
subgraph Customer["Customer Aggregate"]
|
||||
C["Customer (standalone)"]
|
||||
end
|
||||
subgraph Product["Product Aggregate"]
|
||||
P["Product (standalone)"]
|
||||
end
|
||||
|
||||
Order -.->|customerId| Customer
|
||||
Order -.->|productId| Product
|
||||
|
||||
style Order fill:#10b981,stroke:#059669,color:white
|
||||
style Customer fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style Product fill:#3b82f6,stroke:#2563eb,color:white
|
||||
```
|
||||
|
||||
*Reference by ID only*
|
||||
|
||||
**Bad: God Aggregate**
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph GodOrder["Order (God Aggregate)"]
|
||||
O2["Order"]
|
||||
C2["Customer (embedded)"]
|
||||
P2["Products (embedded)"]
|
||||
SA["ShippingAddress (embedded)"]
|
||||
end
|
||||
|
||||
style GodOrder fill:#ef4444,stroke:#dc2626,color:white
|
||||
```
|
||||
|
||||
*Too large, too many reasons to change, contention issues*
|
||||
|
||||
### Pattern
|
||||
|
||||
```
|
||||
abstract class AggregateRoot<ID> extends Entity<ID>:
|
||||
domainEvents: List<DomainEvent> = []
|
||||
version: int = 0
|
||||
|
||||
addDomainEvent(event: DomainEvent):
|
||||
this.domainEvents.append(event)
|
||||
|
||||
clearDomainEvents():
|
||||
this.domainEvents = []
|
||||
|
||||
class Order extends AggregateRoot<OrderId>:
|
||||
customerId: CustomerId
|
||||
items: List<OrderItem> = []
|
||||
status: OrderStatus
|
||||
shippingAddress: Address | null
|
||||
createdAt: DateTime
|
||||
|
||||
static create(customerId: CustomerId) -> Order:
|
||||
order = new Order(
|
||||
id: OrderId.generate(),
|
||||
customerId: customerId,
|
||||
status: DRAFT,
|
||||
createdAt: now()
|
||||
)
|
||||
order.addDomainEvent(OrderCreated{orderId, customerId})
|
||||
return order
|
||||
|
||||
static reconstitute(id, customerId, items, status, ...) -> Order:
|
||||
order = new Order(...)
|
||||
return order
|
||||
|
||||
addItem(productId, quantity, unitPrice):
|
||||
guard: status != CANCELLED
|
||||
guard: status != SHIPPED
|
||||
guard: quantity > 0
|
||||
|
||||
existingItem = this.items.find(i => i.productId == productId)
|
||||
if existingItem:
|
||||
existingItem.increaseQuantity(quantity)
|
||||
else:
|
||||
this.items.append(OrderItem.create(productId, quantity, unitPrice))
|
||||
|
||||
this.addDomainEvent(OrderItemAdded{orderId, productId, quantity})
|
||||
|
||||
removeItem(productId):
|
||||
guard: status != CANCELLED
|
||||
guard: status != SHIPPED
|
||||
guard: item exists
|
||||
|
||||
this.items.remove(productId)
|
||||
this.addDomainEvent(OrderItemRemoved{orderId, productId})
|
||||
|
||||
confirm():
|
||||
guard: status == DRAFT
|
||||
guard: items.length > 0
|
||||
guard: shippingAddress != null
|
||||
|
||||
this.status = CONFIRMED
|
||||
this.addDomainEvent(OrderConfirmed{orderId, total})
|
||||
|
||||
ship(trackingNumber):
|
||||
guard: status == CONFIRMED
|
||||
|
||||
this.status = SHIPPED
|
||||
this.addDomainEvent(OrderShipped{orderId, trackingNumber})
|
||||
|
||||
cancel(reason: string):
|
||||
guard: status not in [SHIPPED, DELIVERED]
|
||||
|
||||
this.status = CANCELLED
|
||||
this.addDomainEvent(OrderCancelled{orderId, reason})
|
||||
|
||||
total() -> Money:
|
||||
return this.items.reduce((sum, item) => sum.add(item.subtotal()), Money.zero())
|
||||
|
||||
itemCount() -> int:
|
||||
return this.items.reduce((sum, item) => sum + item.quantity.value, 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository
|
||||
|
||||
Provides collection-like access to aggregates. Abstracts persistence.
|
||||
|
||||
### Rules
|
||||
|
||||
1. **One repository per aggregate** - Not per entity or table
|
||||
2. **Domain interface** - Interface in domain, implementation in infrastructure
|
||||
3. **Aggregate-focused** - Save/load entire aggregates
|
||||
4. **No query logic** - Complex queries belong in separate read models
|
||||
|
||||
### Pattern
|
||||
|
||||
```
|
||||
interface OrderRepository:
|
||||
findById(id: OrderId) -> Order | null
|
||||
findByCustomerId(customerId: CustomerId) -> List<Order>
|
||||
save(order: Order)
|
||||
delete(order: Order)
|
||||
nextId() -> OrderId
|
||||
|
||||
interface Repository<T extends AggregateRoot<ID>, ID>:
|
||||
findById(id: ID) -> T | null
|
||||
save(aggregate: T)
|
||||
delete(aggregate: T)
|
||||
```
|
||||
|
||||
### Common Mistakes
|
||||
|
||||
**Wrong: Repository per entity**
|
||||
|
||||
```
|
||||
interface OrderItemRepository:
|
||||
findByOrderId(orderId) -> List<OrderItem>
|
||||
save(item: OrderItem)
|
||||
```
|
||||
|
||||
**Wrong: Query methods in repository**
|
||||
|
||||
```
|
||||
interface OrderRepository:
|
||||
findByStatus(status) -> List<Order>
|
||||
findByDateRange(start, end)
|
||||
countByCustomer(customerId)
|
||||
```
|
||||
|
||||
**Correct: Aggregate-focused + separate read model**
|
||||
|
||||
```
|
||||
interface OrderRepository:
|
||||
findById(id: OrderId) -> Order | null
|
||||
save(order: Order)
|
||||
|
||||
interface OrderReadModel:
|
||||
findByStatus(status) -> List<OrderSummaryDTO>
|
||||
findByDateRange(start, end) -> List<OrderSummaryDTO>
|
||||
countByCustomer(customerId) -> int
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Event
|
||||
|
||||
Records something significant that happened in the domain.
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Immutable
|
||||
- Past tense naming (`OrderPlaced`, not `PlaceOrder`)
|
||||
- Contains data needed by consumers
|
||||
- Timestamp when it occurred
|
||||
|
||||
### Pattern
|
||||
|
||||
```
|
||||
abstract class DomainEvent:
|
||||
eventId: string = generateUUID()
|
||||
occurredAt: DateTime = now()
|
||||
abstract eventType: string
|
||||
|
||||
abstract toPayload() -> Map
|
||||
|
||||
class OrderCreated extends DomainEvent:
|
||||
eventType = "order.created"
|
||||
orderId: OrderId
|
||||
customerId: CustomerId
|
||||
|
||||
toPayload():
|
||||
return {orderId: orderId.value, customerId: customerId.value}
|
||||
|
||||
class OrderConfirmed extends DomainEvent:
|
||||
eventType = "order.confirmed"
|
||||
orderId: OrderId
|
||||
total: Money
|
||||
|
||||
toPayload():
|
||||
return {orderId: orderId.value, total: {amount, currency}}
|
||||
|
||||
class OrderShipped extends DomainEvent:
|
||||
eventType = "order.shipped"
|
||||
orderId: OrderId
|
||||
trackingNumber: TrackingNumber
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Service
|
||||
|
||||
Stateless operations that don't naturally fit within an entity or value object.
|
||||
|
||||
### When to Use
|
||||
|
||||
- Operation involves multiple aggregates
|
||||
- Operation requires external information
|
||||
- Significant business logic that doesn't belong to one entity
|
||||
|
||||
### Pattern
|
||||
|
||||
```
|
||||
interface PricingService:
|
||||
calculateDiscount(order: Order, customer: Customer) -> Money
|
||||
|
||||
class PricingServiceImpl implements PricingService:
|
||||
|
||||
calculateDiscount(order, customer) -> Money:
|
||||
discount = Money.zero()
|
||||
|
||||
if order.itemCount() > 10:
|
||||
discount = discount.add(order.total().multiply(0.05))
|
||||
|
||||
if customer.isVIP:
|
||||
discount = discount.add(order.total().multiply(0.10))
|
||||
|
||||
maxDiscount = order.total().multiply(0.20)
|
||||
return min(discount, maxDiscount)
|
||||
|
||||
interface ShippingCostCalculator:
|
||||
calculate(items: List<OrderItem>, destination: Address) -> Money
|
||||
|
||||
class ShippingCostCalculatorImpl implements ShippingCostCalculator:
|
||||
|
||||
calculate(items, destination) -> Money:
|
||||
baseRate = Money.create(5.99, "USD")
|
||||
perItemRate = Money.create(1.50, "USD")
|
||||
|
||||
total = baseRate.add(perItemRate.multiply(items.length))
|
||||
|
||||
if destination.country != "US":
|
||||
total = total.add(Money.create(15.00, "USD"))
|
||||
|
||||
return total
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Factory
|
||||
|
||||
Encapsulates complex aggregate/entity creation.
|
||||
|
||||
### When to Use
|
||||
|
||||
- Creation logic is complex
|
||||
- Need to enforce invariants during creation
|
||||
- Need to create object graphs
|
||||
|
||||
### Pattern
|
||||
|
||||
```
|
||||
interface OrderFactory:
|
||||
createFromCart(cart: Cart, customer: Customer) -> Order
|
||||
|
||||
class OrderFactoryImpl implements OrderFactory:
|
||||
pricingService: PricingService
|
||||
|
||||
createFromCart(cart, customer) -> Order:
|
||||
guard: not cart.isEmpty
|
||||
|
||||
order = Order.create(customer.id)
|
||||
|
||||
for cartItem in cart.items:
|
||||
order.addItem(
|
||||
cartItem.productId,
|
||||
Quantity.create(cartItem.quantity),
|
||||
cartItem.unitPrice
|
||||
)
|
||||
|
||||
if customer.defaultAddress:
|
||||
order.setShippingAddress(customer.defaultAddress)
|
||||
|
||||
return order
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Specification Pattern
|
||||
|
||||
Encapsulates business rules for querying or validation.
|
||||
|
||||
```
|
||||
interface Specification<T>:
|
||||
isSatisfiedBy(candidate: T) -> bool
|
||||
and(other: Specification<T>) -> Specification<T>
|
||||
or(other: Specification<T>) -> Specification<T>
|
||||
not() -> Specification<T>
|
||||
|
||||
class OrderOverValueSpec implements Specification<Order>:
|
||||
minValue: Money
|
||||
|
||||
isSatisfiedBy(order) -> bool:
|
||||
return order.total().amount >= minValue.amount
|
||||
|
||||
class OrderHasItemsSpec implements Specification<Order>:
|
||||
|
||||
isSatisfiedBy(order) -> bool:
|
||||
return order.items.length > 0
|
||||
|
||||
canShipFree = OrderOverValueSpec(Money.create(100, "USD"))
|
||||
.and(OrderHasItemsSpec())
|
||||
|
||||
if canShipFree.isSatisfiedBy(order):
|
||||
applyFreeShipping()
|
||||
```
|
||||
482
.agents/skills/clean-ddd-hexagonal/references/HEXAGONAL.md
Normal file
482
.agents/skills/clean-ddd-hexagonal/references/HEXAGONAL.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# Hexagonal Architecture (Ports & Adapters)
|
||||
|
||||
> Sources:
|
||||
> - [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) — Alistair Cockburn (2005)
|
||||
> - [Hexagonal Architecture Explained](https://openlibrary.org/works/OL38388131W) — Alistair Cockburn & Juan Manuel Garrido de Paz (2024)
|
||||
> - [Interview with Alistair Cockburn](https://jmgarridopaz.github.io/content/interviewalistair.html) — Juan Manuel Garrido de Paz
|
||||
> - [Hexagonal Architecture Pattern](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/hexagonal-architecture.html) — AWS
|
||||
|
||||
## Core Concept
|
||||
|
||||
> "Allow an application to equally be driven by users, programs, automated tests, or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."
|
||||
> — Alistair Cockburn
|
||||
|
||||
**Design validation technique:** The pattern was designed with FIT testing in mind—business experts can write test cases before any GUI exists. If you can run your entire application from test fixtures, your hexagonal boundaries are correct.
|
||||
|
||||
**The hexagon is conceptual.** Most applications have 2-4 ports, not six. The shape emphasizes that all external interactions go through ports, regardless of direction.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph DriverSide["DRIVER SIDE (Primary / Inbound / Left)"]
|
||||
REST["REST API Adapter"]
|
||||
CLI["CLI Adapter"]
|
||||
DriverPorts["DRIVER PORTS\n(Use Case Interfaces)"]
|
||||
REST --> DriverPorts
|
||||
CLI --> DriverPorts
|
||||
end
|
||||
|
||||
subgraph Hexagon["THE HEXAGON"]
|
||||
subgraph AppCore["APPLICATION CORE"]
|
||||
subgraph Domain["DOMAIN\n(Business Logic)"]
|
||||
BL[" "]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
subgraph DrivenSide["DRIVEN SIDE (Secondary / Outbound / Right)"]
|
||||
DrivenPorts["DRIVEN PORTS\n(Repository Interfaces)"]
|
||||
Postgres["Postgres Adapter"]
|
||||
RabbitMQ["RabbitMQ Adapter"]
|
||||
DrivenPorts --> Postgres
|
||||
DrivenPorts --> RabbitMQ
|
||||
end
|
||||
|
||||
DriverPorts --> AppCore
|
||||
AppCore --> DrivenPorts
|
||||
|
||||
style DriverSide fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style Hexagon fill:#10b981,stroke:#059669,color:white
|
||||
style DrivenSide fill:#f59e0b,stroke:#d97706,color:white
|
||||
style Domain fill:#059669,stroke:#047857,color:white
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ports
|
||||
|
||||
Interfaces defining how the application communicates with the outside world.
|
||||
|
||||
### Driver Ports (Primary / Inbound)
|
||||
|
||||
Define **how the world uses your application**.
|
||||
|
||||
- Entry points to the application
|
||||
- Called by adapters
|
||||
- Represent use cases
|
||||
|
||||
```typescript
|
||||
// application/ports/driver/place_order_port.ts
|
||||
export interface IPlaceOrderPort {
|
||||
execute(command: PlaceOrderCommand): Promise<OrderId>;
|
||||
}
|
||||
|
||||
// application/ports/driver/get_order_port.ts
|
||||
export interface IGetOrderPort {
|
||||
execute(query: GetOrderQuery): Promise<OrderDTO | null>;
|
||||
}
|
||||
|
||||
// application/ports/driver/cancel_order_port.ts
|
||||
export interface ICancelOrderPort {
|
||||
execute(command: CancelOrderCommand): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Driven Ports (Secondary / Outbound)
|
||||
|
||||
Define **how your application uses external systems**.
|
||||
|
||||
- Dependencies the application needs
|
||||
- Implemented by adapters
|
||||
- Application calls these interfaces
|
||||
|
||||
```typescript
|
||||
// application/ports/driven/order_repository_port.ts
|
||||
export interface IOrderRepositoryPort {
|
||||
findById(id: OrderId): Promise<Order | null>;
|
||||
save(order: Order): Promise<void>;
|
||||
delete(order: Order): Promise<void>;
|
||||
}
|
||||
|
||||
// application/ports/driven/event_publisher_port.ts
|
||||
export interface IEventPublisherPort {
|
||||
publish(event: DomainEvent): Promise<void>;
|
||||
publishAll(events: DomainEvent[]): Promise<void>;
|
||||
}
|
||||
|
||||
// application/ports/driven/payment_gateway_port.ts
|
||||
export interface IPaymentGatewayPort {
|
||||
charge(amount: Money, paymentMethod: PaymentMethod): Promise<PaymentResult>;
|
||||
refund(paymentId: PaymentId, amount: Money): Promise<RefundResult>;
|
||||
}
|
||||
|
||||
// application/ports/driven/notification_port.ts
|
||||
export interface INotificationPort {
|
||||
sendEmail(to: Email, template: EmailTemplate): Promise<void>;
|
||||
sendSMS(to: PhoneNumber, message: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adapters
|
||||
|
||||
Concrete implementations that connect ports to external technologies.
|
||||
|
||||
### Driver Adapters (Primary / Inbound)
|
||||
|
||||
Convert external inputs to port calls.
|
||||
|
||||
```typescript
|
||||
// infrastructure/adapters/driver/rest/order_controller.ts
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
|
||||
import { IGetOrderPort } from '@/application/ports/driver/get_order_port';
|
||||
|
||||
export class OrderController {
|
||||
constructor(
|
||||
private readonly placeOrder: IPlaceOrderPort,
|
||||
private readonly getOrder: IGetOrderPort,
|
||||
) {}
|
||||
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
const command: PlaceOrderCommand = {
|
||||
customerId: req.user.id,
|
||||
items: req.body.items.map((item: any) => ({
|
||||
productId: item.product_id,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
};
|
||||
|
||||
const orderId = await this.placeOrder.execute(command);
|
||||
res.status(201).json({ id: orderId.value });
|
||||
}
|
||||
|
||||
async show(req: Request, res: Response): Promise<void> {
|
||||
const order = await this.getOrder.execute({ orderId: req.params.id });
|
||||
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Order not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(order);
|
||||
}
|
||||
}
|
||||
|
||||
// infrastructure/adapters/driver/grpc/order_service.ts
|
||||
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
|
||||
import { OrderServiceServer, PlaceOrderRequest, PlaceOrderResponse } from './generated/order_pb';
|
||||
|
||||
export class GrpcOrderService implements OrderServiceServer {
|
||||
constructor(private readonly placeOrder: IPlaceOrderPort) {}
|
||||
|
||||
async placeOrder(
|
||||
request: PlaceOrderRequest,
|
||||
): Promise<PlaceOrderResponse> {
|
||||
const command: PlaceOrderCommand = {
|
||||
customerId: request.getCustomerId(),
|
||||
items: request.getItemsList().map(item => ({
|
||||
productId: item.getProductId(),
|
||||
quantity: item.getQuantity(),
|
||||
})),
|
||||
};
|
||||
|
||||
const orderId = await this.placeOrder.execute(command);
|
||||
|
||||
const response = new PlaceOrderResponse();
|
||||
response.setOrderId(orderId.value);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// infrastructure/adapters/driver/cli/place_order_command.ts
|
||||
import { Command } from 'commander';
|
||||
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
|
||||
|
||||
export function createPlaceOrderCommand(placeOrder: IPlaceOrderPort): Command {
|
||||
return new Command('place-order')
|
||||
.description('Place a new order')
|
||||
.requiredOption('-c, --customer <id>', 'Customer ID')
|
||||
.requiredOption('-p, --product <id>', 'Product ID')
|
||||
.requiredOption('-q, --quantity <number>', 'Quantity', parseInt)
|
||||
.action(async (options) => {
|
||||
const orderId = await placeOrder.execute({
|
||||
customerId: options.customer,
|
||||
items: [{ productId: options.product, quantity: options.quantity }],
|
||||
});
|
||||
|
||||
console.log(`Order created: ${orderId.value}`);
|
||||
});
|
||||
}
|
||||
|
||||
// infrastructure/adapters/driver/message/order_message_handler.ts
|
||||
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
|
||||
|
||||
export class OrderMessageHandler {
|
||||
constructor(private readonly placeOrder: IPlaceOrderPort) {}
|
||||
|
||||
async handlePlaceOrderMessage(message: PlaceOrderMessage): Promise<void> {
|
||||
await this.placeOrder.execute({
|
||||
customerId: message.customerId,
|
||||
items: message.items,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Driven Adapters (Secondary / Outbound)
|
||||
|
||||
Implement port interfaces using specific technologies.
|
||||
|
||||
```
|
||||
class PostgresOrderRepository implements IOrderRepositoryPort:
|
||||
db: Database
|
||||
|
||||
findById(id: OrderId) -> Order | null:
|
||||
row = db.orders.where(id: id.value).first()
|
||||
if not row:
|
||||
return null
|
||||
return OrderMapper.toDomain(row)
|
||||
|
||||
save(order: Order):
|
||||
data = OrderMapper.toPersistence(order)
|
||||
db.orders.upsert(data)
|
||||
|
||||
delete(order: Order):
|
||||
db.orders.where(id: order.id.value).delete()
|
||||
```
|
||||
|
||||
**In-Memory (for tests):**
|
||||
|
||||
```
|
||||
class InMemoryOrderRepository implements IOrderRepositoryPort:
|
||||
orders: Map<string, Order> = {}
|
||||
|
||||
findById(id: OrderId) -> Order | null:
|
||||
return orders.get(id.value) or null
|
||||
|
||||
save(order: Order):
|
||||
orders.set(order.id.value, order)
|
||||
|
||||
delete(order: Order):
|
||||
orders.delete(order.id.value)
|
||||
|
||||
clear():
|
||||
orders.clear()
|
||||
```
|
||||
|
||||
**Payment Gateway:**
|
||||
|
||||
```
|
||||
class StripePaymentGateway implements IPaymentGatewayPort:
|
||||
stripe: StripeClient
|
||||
|
||||
charge(amount: Money, paymentMethod: PaymentMethod) -> PaymentResult:
|
||||
try:
|
||||
intent = stripe.paymentIntents.create({
|
||||
amount: amount.cents,
|
||||
currency: amount.currency,
|
||||
paymentMethod: paymentMethod.stripeId,
|
||||
confirm: true
|
||||
})
|
||||
return PaymentResult.success(PaymentId.from(intent.id))
|
||||
catch CardError as error:
|
||||
return PaymentResult.failed(error.message)
|
||||
|
||||
refund(paymentId: PaymentId, amount: Money) -> RefundResult:
|
||||
refund = stripe.refunds.create({paymentIntent: paymentId.value, amount: amount.cents})
|
||||
return RefundResult.success(RefundId.from(refund.id))
|
||||
```
|
||||
|
||||
**Event Publisher:**
|
||||
|
||||
```
|
||||
class RabbitMQEventPublisher implements IEventPublisherPort:
|
||||
channel: Channel
|
||||
|
||||
publish(event: DomainEvent):
|
||||
channel.publish("domain_events", event.eventType, serialize({
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
occurredAt: event.occurredAt,
|
||||
payload: event.toPayload()
|
||||
}))
|
||||
|
||||
publishAll(events: List<DomainEvent>):
|
||||
for event in events:
|
||||
publish(event)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Alistair Cockburn's Recommended Pattern
|
||||
|
||||
**Ports:** `For[Doing][Something]`
|
||||
- Driver: `ForPlacingOrders`, `ForConfiguringSettings`
|
||||
- Driven: `ForStoringUsers`, `ForNotifyingAlerts`
|
||||
|
||||
**Adapters:** Reference the technology
|
||||
- `CliCommandForPlacingOrders`
|
||||
- `MysqlDatabaseForStoringUsers`
|
||||
- `SlackNotifierForAlerts`
|
||||
|
||||
### Alternative Patterns
|
||||
|
||||
| Pattern | Port | Adapter |
|
||||
|---------|------|---------|
|
||||
| Interface/Impl | `IOrderRepository` | `PostgresOrderRepository` |
|
||||
| Port suffix | `OrderRepositoryPort` | `PostgresOrderAdapter` |
|
||||
| Using prefix | `IOrderStorage` | `OrderStorageUsingPostgres` |
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── application/
|
||||
│ ├── ports/
|
||||
│ │ ├── driver/ # Inbound ports
|
||||
│ │ │ ├── place_order_port.ts
|
||||
│ │ │ ├── get_order_port.ts
|
||||
│ │ │ └── cancel_order_port.ts
|
||||
│ │ └── driven/ # Outbound ports
|
||||
│ │ ├── order_repository_port.ts
|
||||
│ │ ├── event_publisher_port.ts
|
||||
│ │ └── payment_gateway_port.ts
|
||||
│ └── use_cases/
|
||||
│ ├── place_order/
|
||||
│ │ └── handler.ts # Implements driver port
|
||||
│ └── get_order/
|
||||
│ └── handler.ts
|
||||
├── infrastructure/
|
||||
│ └── adapters/
|
||||
│ ├── driver/ # Inbound adapters
|
||||
│ │ ├── rest/
|
||||
│ │ │ └── order_controller.ts
|
||||
│ │ ├── grpc/
|
||||
│ │ │ └── order_service.ts
|
||||
│ │ └── cli/
|
||||
│ │ └── commands.ts
|
||||
│ └── driven/ # Outbound adapters
|
||||
│ ├── postgres/
|
||||
│ │ └── order_repository.ts
|
||||
│ ├── rabbitmq/
|
||||
│ │ └── event_publisher.ts
|
||||
│ ├── stripe/
|
||||
│ │ └── payment_gateway.ts
|
||||
│ └── in_memory/ # Test adapters
|
||||
│ ├── order_repository.ts
|
||||
│ └── event_publisher.ts
|
||||
└── domain/
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Asymmetry
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Driver["DRIVER (Left)"]
|
||||
direction TB
|
||||
DA["Adapter\n(Controller)"]
|
||||
DP["Port\n(Interface)"]
|
||||
DA -->|calls| DP
|
||||
end
|
||||
|
||||
subgraph Driven["DRIVEN (Right)"]
|
||||
direction TB
|
||||
DRP["Port\n(Interface)"]
|
||||
DRA["Adapter\n(Postgres)"]
|
||||
DRA -->|implements| DRP
|
||||
end
|
||||
|
||||
Driver -.->|"Application defines\nwhat it OFFERS"| Note1[" "]
|
||||
Driven -.->|"Application defines\nwhat it NEEDS"| Note2[" "]
|
||||
|
||||
style Driver fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style Driven fill:#f59e0b,stroke:#d97706,color:white
|
||||
style Note1 fill:none,stroke:none
|
||||
style Note2 fill:none,stroke:none
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configurability via Adapters
|
||||
|
||||
The power of hexagonal architecture: swap adapters without changing the core.
|
||||
|
||||
```typescript
|
||||
// infrastructure/config/container.ts
|
||||
|
||||
function configureDevelopment(container: Container): void {
|
||||
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
|
||||
.to(InMemoryOrderRepository);
|
||||
container.bind<IEventPublisherPort>('IEventPublisherPort')
|
||||
.to(InMemoryEventPublisher);
|
||||
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
|
||||
.to(FakePaymentGateway);
|
||||
}
|
||||
|
||||
function configureTest(container: Container): void {
|
||||
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
|
||||
.to(InMemoryOrderRepository);
|
||||
container.bind<IEventPublisherPort>('IEventPublisherPort')
|
||||
.to(SpyEventPublisher);
|
||||
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
|
||||
.to(MockPaymentGateway);
|
||||
}
|
||||
|
||||
function configureProduction(container: Container): void {
|
||||
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
|
||||
.to(PostgresOrderRepository);
|
||||
container.bind<IEventPublisherPort>('IEventPublisherPort')
|
||||
.to(RabbitMQEventPublisher);
|
||||
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
|
||||
.to(StripePaymentGateway);
|
||||
}
|
||||
|
||||
function configureWithMongoDB(container: Container): void {
|
||||
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
|
||||
.to(MongoDBOrderRepository);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Strong vs Weak Hexagonal
|
||||
|
||||
### Weak Implementation
|
||||
|
||||
Port is technology-aware (not truly abstract):
|
||||
|
||||
```typescript
|
||||
// ❌ Weak: Leaks SQL concepts
|
||||
interface IOrderRepository {
|
||||
findByQuery(sql: string, params: any[]): Promise<Order[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### Strong Implementation
|
||||
|
||||
Port is fully technology-agnostic:
|
||||
|
||||
```typescript
|
||||
// ✅ Strong: Pure domain concepts
|
||||
interface IOrderRepository {
|
||||
findById(id: OrderId): Promise<Order | null>;
|
||||
findByCustomer(customerId: CustomerId): Promise<Order[]>;
|
||||
save(order: Order): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Testability** - Swap real adapters for test doubles
|
||||
2. **Flexibility** - Change technologies without changing core
|
||||
3. **Independence** - Develop core without external systems
|
||||
4. **Clear boundaries** - Explicit interfaces between layers
|
||||
5. **Parallel development** - Teams work on different adapters
|
||||
502
.agents/skills/clean-ddd-hexagonal/references/LAYERS.md
Normal file
502
.agents/skills/clean-ddd-hexagonal/references/LAYERS.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# Layer Structure - Complete Reference
|
||||
|
||||
> Sources:
|
||||
> - [The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) — Robert C. Martin
|
||||
> - [Designing a DDD-oriented Microservice](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/ddd-oriented-microservice) — Microsoft
|
||||
> - [Clean Architecture: Standing on the Shoulders of Giants](https://herbertograca.com/2017/09/28/clean-architecture-standing-on-the-shoulders-of-giants/) — Herberto Graça
|
||||
|
||||
## The Four Layers
|
||||
|
||||
| Layer | Responsibility | Dependencies |
|
||||
|-------|---------------|--------------|
|
||||
| **Domain** | Business logic, entities, rules | None (pure) |
|
||||
| **Application** | Use cases, orchestration | Domain |
|
||||
| **Infrastructure** | External systems, frameworks | Application, Domain |
|
||||
| **Presentation** | API/UI entry points | Application |
|
||||
|
||||
---
|
||||
|
||||
## Domain Layer (Innermost)
|
||||
|
||||
The **heart of the system**. Contains business logic and rules with **zero external dependencies**.
|
||||
|
||||
### Contents
|
||||
|
||||
```
|
||||
domain/
|
||||
├── order/ # Aggregate folder
|
||||
│ ├── order.ts # Aggregate root entity
|
||||
│ ├── order_item.ts # Child entity
|
||||
│ ├── value_objects.ts # Money, Address, OrderStatus
|
||||
│ ├── events.ts # OrderPlaced, OrderShipped
|
||||
│ ├── repository.ts # IOrderRepository interface
|
||||
│ ├── services.ts # PricingService, DiscountService
|
||||
│ └── errors.ts # InsufficientStockError
|
||||
├── customer/
|
||||
│ └── ...
|
||||
├── product/
|
||||
│ └── ...
|
||||
└── shared/
|
||||
├── entity.ts # Base Entity class
|
||||
├── aggregate_root.ts # Base AggregateRoot class
|
||||
├── value_object.ts # Base ValueObject class
|
||||
├── domain_event.ts # Base DomainEvent class
|
||||
└── errors.ts # DomainError base
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **No framework imports** - No ORM decorators, no HTTP libraries
|
||||
2. **No infrastructure concerns** - No database, no message queues
|
||||
3. **Pure business logic** - Only language primitives and domain types
|
||||
4. **Rich behavior** - Methods that enforce business rules
|
||||
|
||||
### Example: Domain Entity
|
||||
|
||||
```typescript
|
||||
// domain/order/order.ts
|
||||
import { AggregateRoot } from '../shared/aggregate_root';
|
||||
import { OrderItem } from './order_item';
|
||||
import { Money } from './value_objects';
|
||||
import { OrderPlaced, OrderShipped } from './events';
|
||||
import { InsufficientStockError } from './errors';
|
||||
|
||||
export class Order extends AggregateRoot<OrderId> {
|
||||
private items: OrderItem[] = [];
|
||||
private status: OrderStatus;
|
||||
|
||||
private constructor(id: OrderId, customerId: CustomerId) {
|
||||
super(id);
|
||||
this.customerId = customerId;
|
||||
this.status = OrderStatus.Draft;
|
||||
}
|
||||
|
||||
static create(id: OrderId, customerId: CustomerId): Order {
|
||||
const order = new Order(id, customerId);
|
||||
order.addDomainEvent(new OrderPlaced(id, customerId));
|
||||
return order;
|
||||
}
|
||||
|
||||
addItem(product: Product, quantity: number): void {
|
||||
if (quantity <= 0) {
|
||||
throw new InvalidQuantityError(quantity);
|
||||
}
|
||||
if (!product.hasStock(quantity)) {
|
||||
throw new InsufficientStockError(product.id, quantity);
|
||||
}
|
||||
|
||||
const existingItem = this.items.find(i => i.productId.equals(product.id));
|
||||
if (existingItem) {
|
||||
existingItem.increaseQuantity(quantity);
|
||||
} else {
|
||||
this.items.push(OrderItem.create(product.id, product.price, quantity));
|
||||
}
|
||||
}
|
||||
|
||||
ship(): void {
|
||||
if (this.status !== OrderStatus.Confirmed) {
|
||||
throw new InvalidOrderStateError('Cannot ship unconfirmed order');
|
||||
}
|
||||
this.status = OrderStatus.Shipped;
|
||||
this.addDomainEvent(new OrderShipped(this.id));
|
||||
}
|
||||
|
||||
get total(): Money {
|
||||
return this.items.reduce(
|
||||
(sum, item) => sum.add(item.subtotal),
|
||||
Money.zero()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Application Layer
|
||||
|
||||
Orchestrates use cases by coordinating domain objects. Contains **application-specific business rules**.
|
||||
|
||||
### Contents
|
||||
|
||||
```
|
||||
application/
|
||||
├── orders/
|
||||
│ ├── place_order/
|
||||
│ │ ├── command.ts # PlaceOrderCommand DTO
|
||||
│ │ ├── handler.ts # PlaceOrderHandler
|
||||
│ │ └── port.ts # IPlaceOrderUseCase interface
|
||||
│ ├── ship_order/
|
||||
│ │ └── ...
|
||||
│ └── get_order/
|
||||
│ ├── query.ts # GetOrderQuery DTO
|
||||
│ ├── handler.ts # GetOrderHandler
|
||||
│ └── result.ts # OrderDTO response
|
||||
├── shared/
|
||||
│ ├── unit_of_work.ts # IUnitOfWork interface
|
||||
│ ├── event_publisher.ts # IEventPublisher interface
|
||||
│ └── errors.ts # ApplicationError base
|
||||
└── index.ts # Public API exports
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Depends only on Domain** - No infrastructure imports
|
||||
2. **Defines ports** - Interfaces for repositories, external services
|
||||
3. **Orchestrates, doesn't implement** - Calls domain methods
|
||||
4. **Transaction boundary** - Manages unit of work
|
||||
|
||||
### Example: Use Case Handler
|
||||
|
||||
```typescript
|
||||
// application/orders/place_order/handler.ts
|
||||
import { Order } from '@/domain/order/order';
|
||||
import { IOrderRepository } from '@/domain/order/repository';
|
||||
import { IProductRepository } from '@/domain/product/repository';
|
||||
import { IUnitOfWork } from '@/application/shared/unit_of_work';
|
||||
import { IEventPublisher } from '@/application/shared/event_publisher';
|
||||
import { PlaceOrderCommand } from './command';
|
||||
import { OrderNotFoundError, ProductNotFoundError } from '@/application/shared/errors';
|
||||
|
||||
export interface IPlaceOrderUseCase {
|
||||
execute(command: PlaceOrderCommand): Promise<OrderId>;
|
||||
}
|
||||
|
||||
export class PlaceOrderHandler implements IPlaceOrderUseCase {
|
||||
constructor(
|
||||
private readonly orderRepo: IOrderRepository,
|
||||
private readonly productRepo: IProductRepository,
|
||||
private readonly uow: IUnitOfWork,
|
||||
private readonly eventPublisher: IEventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: PlaceOrderCommand): Promise<OrderId> {
|
||||
await this.uow.begin();
|
||||
|
||||
try {
|
||||
const orderId = OrderId.generate();
|
||||
const order = Order.create(orderId, command.customerId);
|
||||
|
||||
for (const item of command.items) {
|
||||
const product = await this.productRepo.findById(item.productId);
|
||||
if (!product) {
|
||||
throw new ProductNotFoundError(item.productId);
|
||||
}
|
||||
order.addItem(product, item.quantity);
|
||||
}
|
||||
|
||||
await this.orderRepo.save(order);
|
||||
await this.uow.commit();
|
||||
await this.eventPublisher.publishAll(order.domainEvents);
|
||||
|
||||
return orderId;
|
||||
} catch (error) {
|
||||
await this.uow.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Command/Query DTOs
|
||||
|
||||
```typescript
|
||||
// application/orders/place_order/command.ts
|
||||
export interface PlaceOrderCommand {
|
||||
customerId: string;
|
||||
items: Array<{
|
||||
productId: string;
|
||||
quantity: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// application/orders/get_order/query.ts
|
||||
export interface GetOrderQuery {
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
// application/orders/get_order/result.ts
|
||||
export interface OrderDTO {
|
||||
id: string;
|
||||
customerId: string;
|
||||
status: string;
|
||||
items: Array<{
|
||||
productId: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
subtotal: number;
|
||||
}>;
|
||||
total: number;
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Layer
|
||||
|
||||
Implements interfaces defined in Domain and Application layers. Contains **all external concerns**.
|
||||
|
||||
### Contents
|
||||
|
||||
```
|
||||
infrastructure/
|
||||
├── persistence/
|
||||
│ ├── postgres/
|
||||
│ │ ├── order_repository.ts # PostgresOrderRepository
|
||||
│ │ ├── product_repository.ts
|
||||
│ │ ├── unit_of_work.ts # PostgresUnitOfWork
|
||||
│ │ ├── migrations/
|
||||
│ │ └── mappers/
|
||||
│ │ └── order_mapper.ts # Domain <-> DB mapping
|
||||
│ └── in_memory/
|
||||
│ ├── order_repository.ts # InMemoryOrderRepository (tests)
|
||||
│ └── unit_of_work.ts
|
||||
├── messaging/
|
||||
│ ├── rabbitmq/
|
||||
│ │ └── event_publisher.ts # RabbitMQEventPublisher
|
||||
│ └── in_memory/
|
||||
│ └── event_publisher.ts # InMemoryEventPublisher (tests)
|
||||
├── external/
|
||||
│ ├── payment/
|
||||
│ │ └── stripe_gateway.ts # StripePaymentGateway
|
||||
│ └── shipping/
|
||||
│ └── fedex_service.ts # FedExShippingService
|
||||
├── http/
|
||||
│ ├── rest/
|
||||
│ │ ├── controllers/
|
||||
│ │ │ └── order_controller.ts # REST API adapter
|
||||
│ │ ├── middleware/
|
||||
│ │ └── routes.ts
|
||||
│ └── graphql/
|
||||
│ └── resolvers/
|
||||
├── grpc/
|
||||
│ └── order_service.ts # gRPC adapter
|
||||
└── config/
|
||||
├── container.ts # DI container setup
|
||||
└── env.ts # Environment config
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Implements ports** - Concrete classes for interfaces
|
||||
2. **Contains framework code** - ORM, HTTP frameworks, etc.
|
||||
3. **Maps between layers** - Domain ↔ Database/DTO mapping
|
||||
4. **Easily replaceable** - Can swap Postgres for MongoDB
|
||||
|
||||
### Example: Repository Implementation
|
||||
|
||||
```
|
||||
class PostgresOrderRepository implements IOrderRepository:
|
||||
db: Database
|
||||
|
||||
findById(id: OrderId) -> Order | null:
|
||||
row = db.orders
|
||||
.where(id: id.value)
|
||||
.withRelated("items")
|
||||
.first()
|
||||
|
||||
if not row:
|
||||
return null
|
||||
|
||||
return OrderMapper.toDomain(row)
|
||||
|
||||
save(order: Order):
|
||||
data = OrderMapper.toPersistence(order)
|
||||
db.orders.upsert(data)
|
||||
|
||||
delete(order: Order):
|
||||
db.orders.where(id: order.id.value).delete()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Presentation Layer
|
||||
|
||||
Entry points to the application. Adapts external requests to application commands/queries.
|
||||
|
||||
### Contents
|
||||
|
||||
```
|
||||
presentation/
|
||||
├── rest/
|
||||
│ ├── controllers/
|
||||
│ │ ├── order_controller.ts
|
||||
│ │ └── product_controller.ts
|
||||
│ ├── middleware/
|
||||
│ │ ├── auth.ts
|
||||
│ │ ├── error_handler.ts
|
||||
│ │ └── validation.ts
|
||||
│ ├── dto/
|
||||
│ │ ├── requests/
|
||||
│ │ └── responses/
|
||||
│ └── routes.ts
|
||||
├── grpc/
|
||||
│ └── ...
|
||||
├── graphql/
|
||||
│ └── ...
|
||||
└── cli/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Example: REST Controller
|
||||
|
||||
```typescript
|
||||
// presentation/rest/controllers/order_controller.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { IPlaceOrderUseCase } from '@/application/orders/place_order/port';
|
||||
import { IGetOrderUseCase } from '@/application/orders/get_order/port';
|
||||
import { PlaceOrderRequest } from '../dto/requests/place_order_request';
|
||||
|
||||
export class OrderController {
|
||||
constructor(
|
||||
private readonly placeOrder: IPlaceOrderUseCase,
|
||||
private readonly getOrder: IGetOrderUseCase,
|
||||
) {}
|
||||
|
||||
async create(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const request = req.body as PlaceOrderRequest;
|
||||
|
||||
const orderId = await this.placeOrder.execute({
|
||||
customerId: req.user.id,
|
||||
items: request.items.map(item => ({
|
||||
productId: item.product_id,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
});
|
||||
|
||||
res.status(201).json({ id: orderId.value });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async show(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const order = await this.getOrder.execute({ orderId: req.params.id });
|
||||
|
||||
if (!order) {
|
||||
res.status(404).json({ error: 'Order not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(order);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Presentation["Presentation"]
|
||||
REST["REST Controller"]
|
||||
end
|
||||
|
||||
subgraph Application["Application"]
|
||||
Handler["PlaceOrderHandler"]
|
||||
Port1["IPlaceOrderUseCase (port)"]
|
||||
Port2["IOrderRepository"]
|
||||
Handler -.->|implements| Port1
|
||||
Handler -->|uses| Port2
|
||||
end
|
||||
|
||||
subgraph Domain["Domain"]
|
||||
Aggregate["Order (Aggregate Root)"]
|
||||
RepoInterface["IOrderRepository (interface)"]
|
||||
end
|
||||
|
||||
subgraph Infrastructure["Infrastructure"]
|
||||
PgRepo["PostgresOrderRepository"]
|
||||
RabbitMQ["RabbitMQEventPublisher"]
|
||||
PgRepo -.->|implements| RepoInterface
|
||||
RabbitMQ -.->|implements| EventPub["IEventPublisher"]
|
||||
end
|
||||
|
||||
REST -->|calls| Handler
|
||||
Application -->|defines interfaces| Domain
|
||||
Infrastructure -->|implements| Domain
|
||||
|
||||
style Presentation fill:#f59e0b,stroke:#d97706,color:white
|
||||
style Application fill:#3b82f6,stroke:#2563eb,color:white
|
||||
style Domain fill:#10b981,stroke:#059669,color:white
|
||||
style Infrastructure fill:#6366f1,stroke:#4f46e5,color:white
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composition Root
|
||||
|
||||
All dependencies are wired together at the application entry point.
|
||||
|
||||
```typescript
|
||||
import { Pool } from 'pg';
|
||||
import { Container } from 'inversify';
|
||||
import { IOrderRepository } from '@/domain/order/repository';
|
||||
import { IProductRepository } from '@/domain/product/repository';
|
||||
import { IPlaceOrderUseCase } from '@/application/orders/place_order/port';
|
||||
import { IUnitOfWork } from '@/application/shared/unit_of_work';
|
||||
import { IEventPublisher } from '@/application/shared/event_publisher';
|
||||
import { PlaceOrderHandler } from '@/application/orders/place_order/handler';
|
||||
import { PostgresOrderRepository } from '@/infrastructure/persistence/postgres/order_repository';
|
||||
import { PostgresProductRepository } from '@/infrastructure/persistence/postgres/product_repository';
|
||||
import { PostgresUnitOfWork } from '@/infrastructure/persistence/postgres/unit_of_work';
|
||||
import { RabbitMQEventPublisher } from '@/infrastructure/messaging/rabbitmq/event_publisher';
|
||||
import { OrderController } from '@/presentation/rest/controllers/order_controller';
|
||||
|
||||
export function configureContainer(): Container {
|
||||
const container = new Container();
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
container.bind<Pool>('Pool').toConstantValue(pool);
|
||||
container.bind<IOrderRepository>('IOrderRepository').to(PostgresOrderRepository);
|
||||
container.bind<IProductRepository>('IProductRepository').to(PostgresProductRepository);
|
||||
container.bind<IUnitOfWork>('IUnitOfWork').to(PostgresUnitOfWork);
|
||||
container.bind<IEventPublisher>('IEventPublisher').to(RabbitMQEventPublisher);
|
||||
container.bind<IPlaceOrderUseCase>('IPlaceOrderUseCase').to(PlaceOrderHandler);
|
||||
container.bind<OrderController>(OrderController).toSelf();
|
||||
|
||||
return container;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Language-Agnostic Structure
|
||||
|
||||
The same layered structure applies to any language:
|
||||
|
||||
### Go
|
||||
```
|
||||
internal/
|
||||
├── domain/
|
||||
├── application/
|
||||
├── infrastructure/
|
||||
└── interfaces/ # Presentation
|
||||
```
|
||||
|
||||
### Rust
|
||||
```
|
||||
src/
|
||||
├── domain/
|
||||
├── application/
|
||||
├── infrastructure/
|
||||
└── presentation/
|
||||
```
|
||||
|
||||
### Python
|
||||
```
|
||||
src/
|
||||
├── domain/
|
||||
├── application/
|
||||
├── infrastructure/
|
||||
└── presentation/
|
||||
```
|
||||
|
||||
The key is **dependency direction**: outer layers import inner layers, never the reverse.
|
||||
684
.agents/skills/clean-ddd-hexagonal/references/TESTING.md
Normal file
684
.agents/skills/clean-ddd-hexagonal/references/TESTING.md
Normal file
@@ -0,0 +1,684 @@
|
||||
# Testing Patterns
|
||||
|
||||
> Sources:
|
||||
> - [The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) — Robert C. Martin
|
||||
> - [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) — Alistair Cockburn
|
||||
> - [Unit Testing](https://martinfowler.com/bliki/UnitTest.html) — Martin Fowler
|
||||
> - [Test Pyramid](https://martinfowler.com/bliki/TestPyramid.html) — Martin Fowler
|
||||
|
||||
Testing strategies for Clean Architecture + DDD + Hexagonal systems.
|
||||
|
||||
## Testing Pyramid
|
||||
|
||||
```mermaid
|
||||
%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px'}}}%%
|
||||
flowchart TB
|
||||
subgraph Pyramid["Testing Pyramid"]
|
||||
E2E["E2E Tests\nFew, slow, expensive"]
|
||||
Integration["Integration Tests\nSome, moderate speed"]
|
||||
Unit["Unit Tests (Domain & Application)\nMany, fast, cheap"]
|
||||
end
|
||||
|
||||
E2E --- Integration
|
||||
Integration --- Unit
|
||||
|
||||
style E2E fill:#ef4444,stroke:#dc2626,color:white
|
||||
style Integration fill:#f59e0b,stroke:#d97706,color:white
|
||||
style Unit fill:#10b981,stroke:#059669,color:white
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unit Tests
|
||||
|
||||
### Domain Layer Tests
|
||||
|
||||
Test business logic in isolation. **No mocks needed**—domain has no dependencies.
|
||||
|
||||
```typescript
|
||||
// tests/domain/order/order.test.ts
|
||||
describe('Order', () => {
|
||||
describe('create', () => {
|
||||
it('creates order with draft status', () => {
|
||||
const customerId = CustomerId.from('cust-123');
|
||||
|
||||
const order = Order.create(customerId);
|
||||
|
||||
expect(order.status).toBe(OrderStatus.Draft);
|
||||
expect(order.customerId).toEqual(customerId);
|
||||
expect(order.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emits OrderCreated event', () => {
|
||||
const customerId = CustomerId.from('cust-123');
|
||||
|
||||
const order = Order.create(customerId);
|
||||
|
||||
expect(order.domainEvents).toHaveLength(1);
|
||||
expect(order.domainEvents[0]).toBeInstanceOf(OrderCreated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addItem', () => {
|
||||
it('adds item to order', () => {
|
||||
const order = createDraftOrder();
|
||||
const productId = ProductId.from('prod-123');
|
||||
const quantity = Quantity.create(2);
|
||||
const price = Money.create(10.00, 'USD');
|
||||
|
||||
order.addItem(productId, quantity, price);
|
||||
|
||||
expect(order.items).toHaveLength(1);
|
||||
expect(order.items[0].productId).toEqual(productId);
|
||||
expect(order.items[0].quantity).toEqual(quantity);
|
||||
});
|
||||
|
||||
it('increases quantity for existing product', () => {
|
||||
const order = createDraftOrder();
|
||||
const productId = ProductId.from('prod-123');
|
||||
const price = Money.create(10.00, 'USD');
|
||||
|
||||
order.addItem(productId, Quantity.create(2), price);
|
||||
order.addItem(productId, Quantity.create(3), price);
|
||||
|
||||
expect(order.items).toHaveLength(1);
|
||||
expect(order.items[0].quantity.value).toBe(5);
|
||||
});
|
||||
|
||||
it('throws when order is cancelled', () => {
|
||||
const order = createCancelledOrder();
|
||||
|
||||
expect(() => {
|
||||
order.addItem(ProductId.from('prod-123'), Quantity.create(1), Money.create(10, 'USD'));
|
||||
}).toThrow(InvalidOrderStateError);
|
||||
});
|
||||
|
||||
it('throws when quantity is zero', () => {
|
||||
const order = createDraftOrder();
|
||||
|
||||
expect(() => {
|
||||
order.addItem(ProductId.from('prod-123'), Quantity.create(0), Money.create(10, 'USD'));
|
||||
}).toThrow(InvalidQuantityError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirm', () => {
|
||||
it('changes status to confirmed', () => {
|
||||
const order = createOrderWithItems();
|
||||
|
||||
order.confirm();
|
||||
|
||||
expect(order.status).toBe(OrderStatus.Confirmed);
|
||||
});
|
||||
|
||||
it('emits OrderConfirmed event', () => {
|
||||
const order = createOrderWithItems();
|
||||
|
||||
order.confirm();
|
||||
|
||||
const events = order.domainEvents.filter(e => e instanceof OrderConfirmed);
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws when order is empty', () => {
|
||||
const order = createDraftOrder();
|
||||
|
||||
expect(() => order.confirm()).toThrow(EmptyOrderError);
|
||||
});
|
||||
|
||||
it('throws when already confirmed', () => {
|
||||
const order = createConfirmedOrder();
|
||||
|
||||
expect(() => order.confirm()).toThrow(InvalidOrderStateError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('total', () => {
|
||||
it('calculates total from all items', () => {
|
||||
const order = createDraftOrder();
|
||||
order.addItem(ProductId.from('p1'), Quantity.create(2), Money.create(10, 'USD'));
|
||||
order.addItem(ProductId.from('p2'), Quantity.create(1), Money.create(25, 'USD'));
|
||||
|
||||
expect(order.total.amount).toBe(45); // 2*10 + 1*25
|
||||
});
|
||||
|
||||
it('returns zero for empty order', () => {
|
||||
const order = createDraftOrder();
|
||||
|
||||
expect(order.total.amount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test helpers (builders)
|
||||
function createDraftOrder(): Order {
|
||||
return Order.create(CustomerId.from('cust-123'));
|
||||
}
|
||||
|
||||
function createOrderWithItems(): Order {
|
||||
const order = createDraftOrder();
|
||||
order.addItem(ProductId.from('prod-123'), Quantity.create(1), Money.create(10, 'USD'));
|
||||
return order;
|
||||
}
|
||||
|
||||
function createConfirmedOrder(): Order {
|
||||
const order = createOrderWithItems();
|
||||
order.setShippingAddress(createTestAddress());
|
||||
order.confirm();
|
||||
return order;
|
||||
}
|
||||
|
||||
function createCancelledOrder(): Order {
|
||||
const order = createOrderWithItems();
|
||||
order.cancel('Test cancellation');
|
||||
return order;
|
||||
}
|
||||
```
|
||||
|
||||
### Value Object Tests
|
||||
|
||||
```typescript
|
||||
// tests/domain/shared/money.test.ts
|
||||
describe('Money', () => {
|
||||
describe('create', () => {
|
||||
it('creates money with valid amount', () => {
|
||||
const money = Money.create(10.50, 'USD');
|
||||
|
||||
expect(money.amount).toBe(10.50);
|
||||
expect(money.currency).toBe('USD');
|
||||
});
|
||||
|
||||
it('throws for negative amount', () => {
|
||||
expect(() => Money.create(-1, 'USD')).toThrow(InvalidMoneyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
it('adds two money values with same currency', () => {
|
||||
const a = Money.create(10, 'USD');
|
||||
const b = Money.create(20, 'USD');
|
||||
|
||||
const result = a.add(b);
|
||||
|
||||
expect(result.amount).toBe(30);
|
||||
expect(result.currency).toBe('USD');
|
||||
});
|
||||
|
||||
it('throws for different currencies', () => {
|
||||
const usd = Money.create(10, 'USD');
|
||||
const eur = Money.create(10, 'EUR');
|
||||
|
||||
expect(() => usd.add(eur)).toThrow(CurrencyMismatchError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equality', () => {
|
||||
it('equals money with same amount and currency', () => {
|
||||
const a = Money.create(10, 'USD');
|
||||
const b = Money.create(10, 'USD');
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('not equal with different amount', () => {
|
||||
const a = Money.create(10, 'USD');
|
||||
const b = Money.create(20, 'USD');
|
||||
|
||||
expect(a.equals(b)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Application Layer Tests
|
||||
|
||||
Test use cases with mocked ports.
|
||||
|
||||
```typescript
|
||||
// tests/application/place_order/handler.test.ts
|
||||
describe('PlaceOrderHandler', () => {
|
||||
let handler: PlaceOrderHandler;
|
||||
let orderRepo: MockOrderRepository;
|
||||
let productRepo: MockProductRepository;
|
||||
let eventPublisher: MockEventPublisher;
|
||||
|
||||
beforeEach(() => {
|
||||
orderRepo = new MockOrderRepository();
|
||||
productRepo = new MockProductRepository();
|
||||
eventPublisher = new MockEventPublisher();
|
||||
|
||||
handler = new PlaceOrderHandler(orderRepo, productRepo, eventPublisher);
|
||||
});
|
||||
|
||||
it('creates order with items and saves', async () => {
|
||||
productRepo.addProduct(createTestProduct('prod-1', 10.00));
|
||||
productRepo.addProduct(createTestProduct('prod-2', 20.00));
|
||||
|
||||
const command: PlaceOrderCommand = {
|
||||
customerId: 'cust-123',
|
||||
items: [
|
||||
{ productId: 'prod-1', quantity: 2 },
|
||||
{ productId: 'prod-2', quantity: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const orderId = await handler.handle(command);
|
||||
|
||||
expect(orderId).toBeDefined();
|
||||
|
||||
const savedOrder = await orderRepo.findById(OrderId.from(orderId));
|
||||
expect(savedOrder).not.toBeNull();
|
||||
expect(savedOrder!.items).toHaveLength(2);
|
||||
expect(savedOrder!.total.amount).toBe(40); // 2*10 + 1*20
|
||||
});
|
||||
|
||||
it('publishes domain events', async () => {
|
||||
productRepo.addProduct(createTestProduct('prod-1', 10.00));
|
||||
|
||||
const command: PlaceOrderCommand = {
|
||||
customerId: 'cust-123',
|
||||
items: [{ productId: 'prod-1', quantity: 1 }],
|
||||
};
|
||||
|
||||
await handler.handle(command);
|
||||
|
||||
expect(eventPublisher.publishedEvents).toHaveLength(1);
|
||||
expect(eventPublisher.publishedEvents[0]).toBeInstanceOf(OrderCreated);
|
||||
});
|
||||
|
||||
it('throws when product not found', async () => {
|
||||
const command: PlaceOrderCommand = {
|
||||
customerId: 'cust-123',
|
||||
items: [{ productId: 'nonexistent', quantity: 1 }],
|
||||
};
|
||||
|
||||
await expect(handler.handle(command)).rejects.toThrow(ProductNotFoundError);
|
||||
});
|
||||
|
||||
it('rolls back on error', async () => {
|
||||
productRepo.addProduct(createTestProduct('prod-1', 10.00));
|
||||
orderRepo.simulateErrorOnSave();
|
||||
|
||||
const command: PlaceOrderCommand = {
|
||||
customerId: 'cust-123',
|
||||
items: [{ productId: 'prod-1', quantity: 1 }],
|
||||
};
|
||||
|
||||
await expect(handler.handle(command)).rejects.toThrow();
|
||||
expect(orderRepo.savedOrders).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Mock implementations
|
||||
class MockOrderRepository implements IOrderRepository {
|
||||
savedOrders: Order[] = [];
|
||||
private shouldError = false;
|
||||
|
||||
async findById(id: OrderId): Promise<Order | null> {
|
||||
return this.savedOrders.find(o => o.id.equals(id)) ?? null;
|
||||
}
|
||||
|
||||
async save(order: Order): Promise<void> {
|
||||
if (this.shouldError) {
|
||||
throw new Error('Simulated save error');
|
||||
}
|
||||
this.savedOrders.push(order);
|
||||
}
|
||||
|
||||
async delete(order: Order): Promise<void> {
|
||||
const index = this.savedOrders.findIndex(o => o.id.equals(order.id));
|
||||
if (index >= 0) {
|
||||
this.savedOrders.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
simulateErrorOnSave(): void {
|
||||
this.shouldError = true;
|
||||
}
|
||||
}
|
||||
|
||||
class MockEventPublisher implements IEventPublisher {
|
||||
publishedEvents: DomainEvent[] = [];
|
||||
|
||||
async publish(event: DomainEvent): Promise<void> {
|
||||
this.publishedEvents.push(event);
|
||||
}
|
||||
|
||||
async publishAll(events: DomainEvent[]): Promise<void> {
|
||||
this.publishedEvents.push(...events);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Tests
|
||||
|
||||
Test adapters with real infrastructure (databases, message brokers).
|
||||
|
||||
```typescript
|
||||
// tests/integration/postgres/order_repository.test.ts
|
||||
describe('PostgresOrderRepository', () => {
|
||||
let pool: Pool;
|
||||
let repository: PostgresOrderRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
|
||||
repository = new PostgresOrderRepository(pool);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pool.query('TRUNCATE orders, order_items CASCADE');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
describe('save and findById', () => {
|
||||
it('persists and retrieves order', async () => {
|
||||
const order = Order.create(CustomerId.from('cust-123'));
|
||||
order.addItem(ProductId.from('prod-1'), Quantity.create(2), Money.create(10, 'USD'));
|
||||
|
||||
await repository.save(order);
|
||||
const retrieved = await repository.findById(order.id);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.id.value).toBe(order.id.value);
|
||||
expect(retrieved!.items).toHaveLength(1);
|
||||
expect(retrieved!.items[0].quantity.value).toBe(2);
|
||||
});
|
||||
|
||||
it('updates existing order', async () => {
|
||||
const order = Order.create(CustomerId.from('cust-123'));
|
||||
order.addItem(ProductId.from('prod-1'), Quantity.create(1), Money.create(10, 'USD'));
|
||||
await repository.save(order);
|
||||
|
||||
order.addItem(ProductId.from('prod-2'), Quantity.create(3), Money.create(20, 'USD'));
|
||||
await repository.save(order);
|
||||
|
||||
const retrieved = await repository.findById(order.id);
|
||||
expect(retrieved!.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns null for nonexistent order', async () => {
|
||||
const result = await repository.findById(OrderId.from('nonexistent'));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('removes order from database', async () => {
|
||||
const order = Order.create(CustomerId.from('cust-123'));
|
||||
await repository.save(order);
|
||||
|
||||
await repository.delete(order);
|
||||
|
||||
const retrieved = await repository.findById(order.id);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### API Integration Tests
|
||||
|
||||
```typescript
|
||||
// tests/integration/http/orders_api.test.ts
|
||||
describe('Orders API', () => {
|
||||
let app: Express;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
|
||||
app = createApp(pool); // Configures real repositories
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.truncate("orders", "order_items", "products");
|
||||
await db.products.insertMany([
|
||||
{ id: "prod-1", name: "Product 1", price: 1000 },
|
||||
{ id: "prod-2", name: "Product 2", price: 2000 }
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
describe('POST /orders', () => {
|
||||
it('creates order and returns 201', async () => {
|
||||
const response = await request(app)
|
||||
.post('/orders')
|
||||
.send({
|
||||
customer_id: 'cust-123',
|
||||
items: [
|
||||
{ product_id: 'prod-1', quantity: 2 },
|
||||
{ product_id: 'prod-2', quantity: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 400 for invalid product', async () => {
|
||||
const response = await request(app)
|
||||
.post('/orders')
|
||||
.send({
|
||||
customer_id: 'cust-123',
|
||||
items: [{ product_id: 'nonexistent', quantity: 1 }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('Product not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /orders/:id', () => {
|
||||
it('returns order details', async () => {
|
||||
const createResponse = await request(app)
|
||||
.post('/orders')
|
||||
.send({
|
||||
customer_id: 'cust-123',
|
||||
items: [{ product_id: 'prod-1', quantity: 2 }],
|
||||
});
|
||||
|
||||
const orderId = createResponse.body.id;
|
||||
const response = await request(app).get(`/orders/${orderId}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(orderId);
|
||||
expect(response.body.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 404 for nonexistent order', async () => {
|
||||
const response = await request(app).get('/orders/nonexistent');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Tests
|
||||
|
||||
Verify architectural rules are followed.
|
||||
|
||||
```typescript
|
||||
// tests/architecture/dependency_rules.test.ts
|
||||
import { filesOfProject } from 'ts-arch';
|
||||
|
||||
describe('Architecture', () => {
|
||||
describe('Dependency Rules', () => {
|
||||
it('domain should not depend on application', async () => {
|
||||
const rule = filesOfProject()
|
||||
.inFolder('domain')
|
||||
.shouldNot()
|
||||
.dependOnFiles()
|
||||
.inFolder('application');
|
||||
|
||||
await expect(rule).toPassAsync();
|
||||
});
|
||||
|
||||
it('domain should not depend on infrastructure', async () => {
|
||||
const rule = filesOfProject()
|
||||
.inFolder('domain')
|
||||
.shouldNot()
|
||||
.dependOnFiles()
|
||||
.inFolder('infrastructure');
|
||||
|
||||
await expect(rule).toPassAsync();
|
||||
});
|
||||
|
||||
it('application should not depend on infrastructure', async () => {
|
||||
const rule = filesOfProject()
|
||||
.inFolder('application')
|
||||
.shouldNot()
|
||||
.dependOnFiles()
|
||||
.inFolder('infrastructure');
|
||||
|
||||
await expect(rule).toPassAsync();
|
||||
});
|
||||
|
||||
it('domain should have no external framework dependencies', async () => {
|
||||
const rule = filesOfProject()
|
||||
.inFolder('domain')
|
||||
.shouldNot()
|
||||
.dependOnFiles()
|
||||
.matchingPattern('node_modules/(express|pg|axios|typeorm)/');
|
||||
|
||||
await expect(rule).toPassAsync();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Naming Conventions', () => {
|
||||
it('repositories should be named *Repository', async () => {
|
||||
const rule = filesOfProject()
|
||||
.inFolder('domain/**/repository')
|
||||
.should()
|
||||
.matchPattern('.*Repository\\.ts$');
|
||||
|
||||
await expect(rule).toPassAsync();
|
||||
});
|
||||
|
||||
it('domain events should be named in past tense', async () => {
|
||||
const rule = filesOfProject()
|
||||
.inFolder('domain/**/events')
|
||||
.should()
|
||||
.matchPattern('.*(Created|Updated|Deleted|Confirmed|Shipped|Cancelled)\\.ts$');
|
||||
|
||||
await expect(rule).toPassAsync();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Organization
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/
|
||||
│ ├── domain/
|
||||
│ │ ├── order/
|
||||
│ │ │ ├── order.test.ts
|
||||
│ │ │ ├── order_item.test.ts
|
||||
│ │ │ └── value_objects.test.ts
|
||||
│ │ └── shared/
|
||||
│ │ ├── money.test.ts
|
||||
│ │ └── email.test.ts
|
||||
│ └── application/
|
||||
│ ├── place_order/
|
||||
│ │ └── handler.test.ts
|
||||
│ └── confirm_order/
|
||||
│ └── handler.test.ts
|
||||
├── integration/
|
||||
│ ├── persistence/
|
||||
│ │ └── postgres_order_repository.test.ts
|
||||
│ ├── messaging/
|
||||
│ │ └── rabbitmq_event_publisher.test.ts
|
||||
│ └── http/
|
||||
│ └── orders_api.test.ts
|
||||
├── e2e/
|
||||
│ └── order_workflow.test.ts
|
||||
├── architecture/
|
||||
│ └── dependency_rules.test.ts
|
||||
├── fixtures/
|
||||
│ ├── order_fixtures.ts
|
||||
│ └── product_fixtures.ts
|
||||
└── helpers/
|
||||
├── test_database.ts
|
||||
└── mock_factories.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Fixtures & Builders
|
||||
|
||||
```typescript
|
||||
// tests/fixtures/order_fixtures.ts
|
||||
export class OrderBuilder {
|
||||
private customerId: CustomerId = CustomerId.from('default-customer');
|
||||
private items: Array<{ productId: ProductId; quantity: Quantity; price: Money }> = [];
|
||||
private status: 'draft' | 'confirmed' | 'shipped' | 'cancelled' = 'draft';
|
||||
|
||||
withCustomer(id: string): this {
|
||||
this.customerId = CustomerId.from(id);
|
||||
return this;
|
||||
}
|
||||
|
||||
withItem(productId: string, quantity: number, price: number): this {
|
||||
this.items.push({
|
||||
productId: ProductId.from(productId),
|
||||
quantity: Quantity.create(quantity),
|
||||
price: Money.create(price, 'USD'),
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
confirmed(): this {
|
||||
this.status = 'confirmed';
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): Order {
|
||||
const order = Order.create(this.customerId);
|
||||
|
||||
for (const item of this.items) {
|
||||
order.addItem(item.productId, item.quantity, item.price);
|
||||
}
|
||||
|
||||
if (this.status === 'confirmed') {
|
||||
order.setShippingAddress(new AddressBuilder().build());
|
||||
order.confirm();
|
||||
}
|
||||
|
||||
order.clearEvents(); // Clear events from building
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const order = new OrderBuilder()
|
||||
.withCustomer('cust-123')
|
||||
.withItem('prod-1', 2, 10.00)
|
||||
.withItem('prod-2', 1, 25.00)
|
||||
.confirmed()
|
||||
.build();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Testing Principles
|
||||
|
||||
1. **Test behavior, not implementation** - Focus on what, not how
|
||||
2. **Domain tests need no mocks** - Domain layer is pure
|
||||
3. **Mock at port boundaries** - Application tests mock driven ports
|
||||
4. **Integration tests use real infra** - Test actual database, message broker
|
||||
5. **Fast unit tests, slower integration** - Run unit tests frequently
|
||||
6. **Test business rules in domain** - Not in application or infrastructure
|
||||
288
.agents/skills/sf-backend-architecture/SKILL.md
Normal file
288
.agents/skills/sf-backend-architecture/SKILL.md
Normal file
@@ -0,0 +1,288 @@
|
||||
---
|
||||
name: sf-backend-architecture
|
||||
description: "Aplicar de forma proactiva al diseñar, planificar, hacer brainstorming, revisar o auditar microservicios backend de Save Family / sf-sim. Cubre Hexagonal + DDD + EDA con RabbitMQ + CQRS en NodeJS/TypeScript siguiendo el estilo de la casa (carpetas domain/aplication/infrastructure/config, ports .port.ts, usecases .usecases.ts, tipo Result genérico, eventos sim.[compañia].[acción], exchange sim.exchange, DLX, retries con x-retry-count). Triggers de uso — planning, brainstorming, 'diseña un servicio', 'añade un comando/evento', 'revisa la arquitectura', 'audita este microservicio', 'esto sigue la arquitectura', bounded context, agregado, port/adapter, puerto/adaptador, outbox, eventual consistency, command/query, retry, dead letter. Úsala SIEMPRE que un usuario hable de añadir, dividir o revisar un microservicio del repo sim-eventos / sf-sim aunque no nombre la arquitectura explícitamente."
|
||||
---
|
||||
|
||||
# sf-backend-architecture
|
||||
|
||||
Skill experta para diseñar y auditar microservicios del monorepo `sim-eventos` (sf-sim), siguiendo Hexagonal + DDD + EDA + CQRS con NodeJS/TypeScript y RabbitMQ.
|
||||
|
||||
Tiene **dos modos de uso**:
|
||||
|
||||
1. **Asesor en planning / brainstorming** — proponer diseño inicial Y empujar las preguntas correctas para no saltarse decisiones críticas.
|
||||
2. **Auditor para agentes revisores** — emitir un informe con secciones fijas y veredicto, comparando un servicio existente contra el estilo de la casa.
|
||||
|
||||
Si la conversación es ambigua sobre qué modo aplicar, pregúntalo antes de actuar.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR del estilo de la casa
|
||||
|
||||
| Aspecto | Convención sf-sim |
|
||||
|---|---|
|
||||
| Carpetas por servicio | `domain/` · `aplication/` (con "p" simple, intencional) · `infrastructure/` · `config/` |
|
||||
| Ports compartidos | `packages/sim-shared/ports/` y `packages/sim-shared/domain/*.port.ts` |
|
||||
| Naming ports | `*.port.ts` (interface o type) |
|
||||
| Naming usecases | `X.usecases.ts` con clase `XUsecases`, `constructor(args: { dep1, dep2, ... })` |
|
||||
| Naming controllers | `X.controller.ts` |
|
||||
| Naming routes | `X.http.ts` o `XRoutes.http.ts` |
|
||||
| Naming repos | `XRepository.ts` en `infrastructure/` |
|
||||
| Errores | `Result<E, D>` (de `sim-shared/domain/Result.ts`) — NO excepciones para flujo de negocio |
|
||||
| Inyección | Manual, por constructor con objeto de dependencias. Composition root en `index.ts` o `config/*.config.ts` |
|
||||
| Bus | `EventBus` (port) → `RabbitMQEventBus` (adapter) |
|
||||
| Routing keys | `sim.[compañia].[acción]` (typed con template literals en `SimEvents`) |
|
||||
| Exchanges | Principal `sim.exchange` (topic) · `sim.dlx` · delayed por servicio |
|
||||
| Retries | Header `x-retry-count`, tras `maxRetry` → DLX |
|
||||
| Persistencia | `pg` puro, transacciones manuales, `correlation_id` (uuidv7) liga evento ↔ order |
|
||||
| ESM | Imports con `.js` aunque el archivo sea `.ts` |
|
||||
|
||||
Si vas a crear un servicio nuevo, parte de `packages/_template/`.
|
||||
|
||||
---
|
||||
|
||||
## Patrones obligatorios en código
|
||||
|
||||
Cuando propongas o revises código TypeScript del repo, estos dos patrones son **convención dura** — no los uses al gusto. Si los rompes en código que propones, te van a corregir; si los detectas rotos en código existente, repórtalo.
|
||||
|
||||
### Inyección por constructor con objeto `args`
|
||||
|
||||
Todas las clases reciben sus dependencias en un objeto, NO posicionalmente. Esto evita errores al añadir deps y hace el cableado explícito.
|
||||
|
||||
```typescript
|
||||
// ✅ Correcto — convención del repo
|
||||
export class SimMovistarUsecases {
|
||||
private movistarRepository: MovistarRepositoryPort;
|
||||
private orderRepository: OrderRepositoryPort;
|
||||
|
||||
constructor(args: {
|
||||
movistarRepository: MovistarRepositoryPort,
|
||||
orderRepository: OrderRepositoryPort,
|
||||
}) {
|
||||
this.movistarRepository = args.movistarRepository;
|
||||
this.orderRepository = args.orderRepository;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Incorrecto — positional, viola la convención
|
||||
export class SimMovistarUsecases {
|
||||
constructor(
|
||||
private movistarRepository: MovistarRepositoryPort,
|
||||
private orderRepository: OrderRepositoryPort,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
Aplica a usecases, controllers, routers, repositorios y adapters. El cableado en `index.ts`/`config/` pasa `{ key: value }` al constructor.
|
||||
|
||||
### `Result<E, D>` para errores esperables
|
||||
|
||||
Funciones de aplicación o I/O esperable devuelven `Result`. NO `throw`. El llamador chequea `if (r.error != undefined)`.
|
||||
|
||||
```typescript
|
||||
// ✅ Correcto
|
||||
async activate(args: { iccid: string }): Promise<Result<string, MovistarLine>> {
|
||||
const r = await this.movistarRepository.activateSim(args.iccid);
|
||||
if (r.error != undefined) return { error: r.error };
|
||||
return { data: r.data };
|
||||
}
|
||||
|
||||
// ❌ Incorrecto — throw para flujo de negocio
|
||||
async activate(args: { iccid: string }): Promise<MovistarLine> {
|
||||
const r = await this.movistarRepository.activateSim(args.iccid);
|
||||
if (!r) throw new Error("activación falló");
|
||||
return r;
|
||||
}
|
||||
```
|
||||
|
||||
`throw` queda reservado para invariantes rotas (bugs, configuración imposible). Detalle completo en [references/HOUSE-STYLE.md](references/HOUSE-STYLE.md#resulted).
|
||||
|
||||
---
|
||||
|
||||
## La regla central: dependencias hacia dentro
|
||||
|
||||
```text
|
||||
infrastructure → aplication → domain
|
||||
(adapters) (use cases) (núcleo)
|
||||
```
|
||||
|
||||
`domain/` no importa nada de `infrastructure/` ni de librerías de I/O (pg, amqplib, axios, express). Si lo hace, es una violación.
|
||||
|
||||
**Por qué importa esto en este repo:** los workers consumen del bus, los gateways escriben en él, y un día nos tocará cambiar de RabbitMQ o de Postgres (o testear sin ellos). Si la lógica de negocio depende del adapter concreto, tenemos que reescribir el dominio. Cuando el dominio sólo conoce el `EventBus.port` y el `XRepository.port`, el adapter es intercambiable y el dominio es testeable sin infra.
|
||||
|
||||
**Validación rápida:** ¿podrías ejecutar el caso de uso desde un test sin Express, sin RabbitMQ y sin Postgres? Si no, los límites están mal.
|
||||
|
||||
---
|
||||
|
||||
## Modo 1 — Asesor en planning / brainstorming
|
||||
|
||||
Cuando el usuario describa una nueva funcionalidad, servicio o bounded context, **haz dos cosas en paralelo**:
|
||||
|
||||
### A. Lanza estas preguntas (no las omitas)
|
||||
|
||||
Lánzalas como bullets, en lenguaje natural, no como interrogatorio. Si ya tienes la respuesta del contexto, dilo y pasa a la siguiente.
|
||||
|
||||
1. **Bounded context y propiedad** — ¿de qué agregado o subdominio es responsable este servicio? ¿Es un consumidor de eventos, un gateway, o ambos? ¿Comparte BDD con otro servicio (mala señal) o tiene la suya?
|
||||
2. **Comandos vs eventos** — ¿qué entra como comando síncrono (HTTP) y qué entra como evento? ¿Qué eventos publica y qué eventos consume?
|
||||
3. **Routing keys** — ¿cuál es el patrón? Por defecto `sim.[compañia].[acción]`. Si introduces un nuevo nivel (`sim.[compañia].[acción].[sub]`) hay que justificarlo, porque rompe los bindings actuales.
|
||||
4. **Idempotencia y orden** — ¿qué pasa si un evento llega dos veces? ¿Puede procesarse fuera de orden? ¿Necesitas `correlation_id`, `causation_id`, o secuencias?
|
||||
5. **Reintentos y fallos** — ¿qué errores son transitorios (delay + retry) y cuáles definitivos (DLX directo)? ¿Cuál es el `maxRetry`? ¿Cómo se monitoriza la DLX?
|
||||
6. **CQRS** — ¿hay separación entre los usecases que escriben (commands) y los que leen (queries)? ¿Ambos hablan con la misma BDD o hay un read model?
|
||||
7. **Outbox / atomicidad** — si el caso de uso debe escribir en BDD Y publicar evento, ¿cómo evitamos perder el evento si el publish falla tras el commit (o viceversa)?
|
||||
8. **Webhook / respuesta externa** — ¿hay un webhook host/endpoint asociado a la order? ¿Quién lo dispara y cuándo?
|
||||
9. **Contratos compartidos** — ¿qué tipos van en `sim-shared` y qué se queda local del servicio? Regla: si más de un servicio lo usa, va a `sim-shared`.
|
||||
10. **Tests** — ¿qué casos vas a probar a nivel de dominio (puro), de aplicación (con ports mockeados) y de infraestructura (con BDD/RMQ reales)?
|
||||
|
||||
### B. Propón un esqueleto de diseño
|
||||
|
||||
Una vez tengas una idea suficiente, escribe un esqueleto concreto:
|
||||
|
||||
- Árbol de carpetas (`domain/`, `aplication/`, `infrastructure/`, `config/`)
|
||||
- Lista de **ports** que el dominio necesita (con su firma TypeScript)
|
||||
- Lista de **adapters** que vas a crear, con qué tecnología
|
||||
- Lista de **usecases** (uno por comando/query)
|
||||
- Lista de **eventos** publicados y consumidos, con su routing key tipada
|
||||
- Lista de **tablas / read models** si aplica
|
||||
- Notas sobre transacciones y consistencia (¿una sola tx? ¿outbox? ¿eventual?)
|
||||
|
||||
No completes detalles que no tengas. Marca con `// TODO` y vuelve a preguntar.
|
||||
|
||||
**Cuándo simplificar:** no todo necesita CQRS estricto, ni Event Sourcing, ni Saga. Si el servicio tiene un agregado pequeño y reglas simples, dilo y propón la versión mínima. La complejidad se mete cuando duele su ausencia, no antes.
|
||||
|
||||
---
|
||||
|
||||
## Modo 2 — Auditor (informe con secciones fijas)
|
||||
|
||||
Cuando un agente revisor te invoque para auditar un servicio o un cambio, devuelve EXACTAMENTE esta plantilla. Las secciones son obligatorias en este orden, aunque alguna salga vacía (di explícitamente "Sin hallazgos").
|
||||
|
||||
```markdown
|
||||
# Auditoría de arquitectura — <nombre del servicio o PR>
|
||||
|
||||
## 1. Resumen ejecutivo
|
||||
<2-4 líneas: qué se ha auditado, veredicto general (OK / OK con observaciones / Bloqueante), top-1 riesgo>
|
||||
|
||||
## 2. Capas y dependencias
|
||||
- ¿`domain/` importa SÓLO domain? (sí/no, evidencias con archivo:línea)
|
||||
- ¿`aplication/` depende de ports y no de adapters concretos? (sí/no, evidencias)
|
||||
- ¿`infrastructure/` implementa ports definidos en `domain/` o `sim-shared`? (sí/no)
|
||||
- Violaciones detectadas (lista con archivo:línea + por qué viola)
|
||||
|
||||
## 3. DDD táctico
|
||||
- Agregados identificados y su raíz
|
||||
- Entidades vs Value Objects (¿alguna inmutabilidad rota?)
|
||||
- Eventos de dominio (nombre en pasado, payload, headers, ¿en `sim-shared/domain`?)
|
||||
- Anti-patrones de dominio (anémico, lógica en services, primitive obsession, ...)
|
||||
|
||||
## 4. Hexagonal — ports & adapters
|
||||
- Ports definidos (nombre, ubicación, ¿tienen sufijo `.port.ts`?)
|
||||
- Adapters (nombre, ubicación, port que implementan)
|
||||
- Composition root (¿dónde se cablean? ¿hay duplicación?)
|
||||
- Acoplamientos sospechosos: import de adapter desde aplication o domain
|
||||
|
||||
## 5. EDA / RabbitMQ
|
||||
- Eventos publicados (routing key, exchange, payload typed)
|
||||
- Eventos consumidos (queue, binding, handler)
|
||||
- Routing keys: ¿siguen `sim.[compañia].[acción]`? Excepciones documentadas
|
||||
- Headers obligatorios: `message_id` (uuidv7), `x-retry-count` para reintentos
|
||||
- Política de retry y DLX: ¿está configurada en este servicio o se asume del shared?
|
||||
- Idempotencia del consumidor: ¿qué pasa con un mensaje duplicado?
|
||||
|
||||
## 6. CQRS
|
||||
- Separación command/query: ¿está? ¿es necesaria aquí?
|
||||
- Read models (si aplica): ubicación, sincronización, lag aceptable
|
||||
- Mezclas problemáticas (un usecase que escribe Y devuelve datos derivados de otra fuente)
|
||||
|
||||
## 7. Persistencia y consistencia
|
||||
- Repositorios: uno por agregado, no por tabla
|
||||
- Transacciones: ¿BEGIN/COMMIT/ROLLBACK correctos? ¿alguna tx que toca varios agregados?
|
||||
- Outbox / atomicidad publish+save: ¿está garantizada o hay ventana de pérdida?
|
||||
- Manejo de `correlation_id` / `message_id`
|
||||
|
||||
## 8. Manejo de errores
|
||||
- ¿Se usa `Result<E, D>` consistentemente?
|
||||
- ¿Hay `throw` que escapan a controllers o handlers?
|
||||
- ¿`tryCatch` se usa en los puntos de I/O?
|
||||
|
||||
## 9. Tests
|
||||
- Cobertura por capa (domain unitario, application con mocks de ports, infra con BDD/RMQ reales)
|
||||
- Tests que ejecutan sin infra (síntoma de buen aislamiento)
|
||||
- Tests críticos ausentes
|
||||
|
||||
## 10. Estilo y convenciones
|
||||
- Naming (`*.port.ts`, `*.usecases.ts`, `*.controller.ts`, `*.http.ts`, `*Repository.ts`)
|
||||
- Ubicación de archivos (¿algo en la capa equivocada?)
|
||||
- ESM imports con `.js` correctos
|
||||
- Path aliases (`#config/*`, `#adapters/*`, `sim-shared/*`)
|
||||
|
||||
## 11. Riesgos priorizados
|
||||
1. **[Bloqueante]** ...
|
||||
2. **[Alto]** ...
|
||||
3. **[Medio]** ...
|
||||
4. **[Bajo / nice-to-have]** ...
|
||||
|
||||
## 12. Acciones recomendadas
|
||||
Lista de cambios concretos, en orden de prioridad. Cada acción referencia archivo y línea.
|
||||
```
|
||||
|
||||
**Reglas del auditor:**
|
||||
|
||||
- Cada hallazgo debe citar `archivo:línea`. Si no puedes, marca "no verificable".
|
||||
- "Veredicto general" sólo es **OK** si no hay nada **Bloqueante** ni **Alto**.
|
||||
- Algo es **Bloqueante** si introduce pérdida de datos, inconsistencia silenciosa, o rompe un contrato publicado (routing key, payload de evento).
|
||||
- No inventes. Si no encuentras tests, di "no encontrados", no "pocos".
|
||||
|
||||
---
|
||||
|
||||
## Decisiones rápidas
|
||||
|
||||
### ¿Dónde va este código?
|
||||
|
||||
```text
|
||||
¿Qué es?
|
||||
├─ Lógica de negocio pura, sin I/O → domain/
|
||||
├─ Orquesta dominio + tiene side effects → aplication/ (usecase)
|
||||
├─ Habla con sistemas externos → infrastructure/ (adapter)
|
||||
├─ Define una interfaz que el dominio usa → port (en domain/ o sim-shared)
|
||||
├─ Implementa un port → adapter en infrastructure/
|
||||
└─ Cableado, env, conexiones → config/
|
||||
```
|
||||
|
||||
### ¿Comando o evento?
|
||||
|
||||
- **Comando** (síncrono, HTTP): el cliente espera una respuesta inmediata, fallar el comando es razonable de cara al cliente. Va por controller → usecase. Devuelve `Result`.
|
||||
- **Evento** (asíncrono, RabbitMQ): notifica un hecho ocurrido. El emisor no espera. El consumidor se encarga de la idempotencia. Routing key en pasado o como verbo de acción según convención del repo (`sim.alai.activate` describe la acción solicitada, no el resultado — es el patrón actual).
|
||||
|
||||
### ¿Es un agregado o lo divido?
|
||||
|
||||
- ¿Deben ser consistentes en una transacción? → mismo agregado.
|
||||
- ¿Pueden ser eventualmente consistentes? → agregados distintos, eventos entre ellos.
|
||||
- ¿Se referencian sólo por id? → agregados distintos.
|
||||
- ¿Más de ~10 entidades dentro? → divídelo.
|
||||
|
||||
Regla operativa: **una transacción toca un solo agregado.** Cross-aggregate va por eventos.
|
||||
|
||||
---
|
||||
|
||||
## Anti-patrones del repo (úsalos como ejemplos)
|
||||
|
||||
Estos están vivos en `sf-sim` y son material didáctico:
|
||||
|
||||
- **Fuga de infra al usecase**: `aplication/Sim.usecases.ts` importa `OrderRepository` concreto en vez del port. El usecase debería depender de un `OrderRepository.port`.
|
||||
- **Excepciones para flujo normal**: `domain/companies.ts` `companyFromIccid` lanza `Error` en vez de devolver `Result`. Esto fuerza try/catch en controllers.
|
||||
- **Publish + save no atómico**: en los usecases de Sim, primero se hace `eventBus.publish` y luego `saveOrder`. Si el publish va y el save falla, hay evento sin order; si el orden se invierte, hay order sin evento. Solución: outbox.
|
||||
- **Controller con demasiadas responsabilidades**: `controllerGenerator` mezcla validación, mapping, ejecución y formateo de respuesta. Funciona, pero esconde el flujo y dificulta tests del controller.
|
||||
|
||||
Lista completa con ejemplos: ver [references/ANTI-PATTERNS.md](references/ANTI-PATTERNS.md).
|
||||
|
||||
---
|
||||
|
||||
## Referencias
|
||||
|
||||
| Archivo | Cuándo leerlo |
|
||||
|---|---|
|
||||
| [references/HOUSE-STYLE.md](references/HOUSE-STYLE.md) | Necesitas detalle de carpetas, naming de ficheros, DI manual, Result, ESM |
|
||||
| [references/CODE-STYLE.md](references/CODE-STYLE.md) | Necesitas detalle de naming a nivel código, idioma de comentarios/identificadores, `interface` vs `type`, política de `any`, async, redacción de tests |
|
||||
| [references/EVENTS-RABBITMQ.md](references/EVENTS-RABBITMQ.md) | Vas a tocar publish/consume, routing keys, retries, DLX, outbox, idempotencia |
|
||||
| [references/ANTI-PATTERNS.md](references/ANTI-PATTERNS.md) | Vas a auditar o ya tienes un olor sospechoso |
|
||||
| [references/AUDIT-CHECKLIST.md](references/AUDIT-CHECKLIST.md) | Vas a emitir el informe del Modo 2; lista exhaustiva de checks |
|
||||
|
||||
Lee SOLO la referencia que necesites. No las cargues todas por defecto.
|
||||
26
.agents/skills/sf-backend-architecture/evals/evals.json
Normal file
26
.agents/skills/sf-backend-architecture/evals/evals.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"skill_name": "sf-backend-architecture",
|
||||
"evals": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "planning-nuevo-operador",
|
||||
"mode": "planning",
|
||||
"prompt": "Tenemos que añadir un nuevo operador al sistema, Movistar, igual que ya tenemos NOS y Objenious. Va a hacer falta poder hacer activaciones, cancelaciones y pausa de las SIMs igual que con los otros, y la prueba que nos pasan dice que el iccid empieza por 3401 (Movistar España). Quiero que me ayudes a planificar el servicio nuevo: por dónde empiezo, qué pongo en sim-shared y qué local del servicio, qué eventos van a llegarle y cómo encajan en sim.exchange. Estoy en una sesión de planning con el equipo y quiero salir con un esqueleto claro y la lista de decisiones que aún no tenemos resueltas.",
|
||||
"expected_output": "Un esqueleto de servicio nuevo (carpetas, ports, usecases, eventos), preguntas concretas sobre las decisiones pendientes (idempotencia, retry, autenticación con Movistar, outbox, etc.), y la actualización del Map COMPANYICCID en domain/companies.ts con 3401->movistar."
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "brainstorming-outbox",
|
||||
"mode": "brainstorming",
|
||||
"prompt": "Tenemos un problema en sim-entrada-eventos: cuando hacemos una activación, primero publicamos el evento al RabbitMQ y luego guardamos la order en Postgres. Si el guardado falla nos quedamos con un evento que el worker procesa pero del que no tenemos seguimiento. Y al revés también: si invierto el orden y el publish falla, hay order pero el worker no se entera. ¿Cómo lo resolvemos? Quiero discutirlo bien antes de implementar nada, no me valen respuestas de manual. Tengo dudas si esto es realmente un outbox o si hay algo más simple.",
|
||||
"expected_output": "Análisis de las opciones (transactional outbox, mensajería de 2-fase, aceptar la ventana y reconciliar a posteriori), tradeoffs de cada una, recomendación basada en el contexto del repo (sim-eventos), y preguntas sobre criticidad del evento (¿perderlo es pérdida de dinero?) para decidir si outbox es bloqueante."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "audit-sim-consumidor-nos",
|
||||
"mode": "audit",
|
||||
"prompt": "Audita el servicio packages/sim-consumidor-nos contra la arquitectura de la casa. Quiero un informe completo con todas las secciones fijas: capas, DDD, hexagonal, EDA, CQRS, persistencia, errores, tests, estilo. Cita archivo:línea para cada hallazgo. Ordena los riesgos por severidad y dame acciones concretas al final.",
|
||||
"expected_output": "Informe en formato fijo (12 secciones desde 'Resumen ejecutivo' hasta 'Acciones recomendadas') con hallazgos citando archivo:línea, veredicto OK/Observaciones/Bloqueante justificado, y acciones priorizadas."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
# Anti-patrones — sf-sim
|
||||
|
||||
Lista de olores arquitectónicos a detectar. Algunos están vivos en el repo y se citan como ejemplo. Para cada uno: por qué es un problema, cómo se ve, cómo se arregla.
|
||||
|
||||
## Tabla de contenidos
|
||||
|
||||
1. [Capas y dependencias](#capas-y-dependencias)
|
||||
2. [DDD táctico](#ddd-táctico)
|
||||
3. [Hexagonal / ports & adapters](#hexagonal--ports--adapters)
|
||||
4. [EDA / RabbitMQ](#eda--rabbitmq)
|
||||
5. [CQRS](#cqrs)
|
||||
6. [Persistencia](#persistencia)
|
||||
7. [Manejo de errores](#manejo-de-errores)
|
||||
8. [Tests](#tests)
|
||||
|
||||
---
|
||||
|
||||
## Capas y dependencias
|
||||
|
||||
### Fuga de infraestructura al usecase
|
||||
|
||||
**Problema:** un caso de uso importa una clase concreta de `infrastructure/` en vez del port. Se rompe la regla de dependencias hacia dentro y el usecase se vuelve no testeable sin la infra.
|
||||
|
||||
**Ejemplo en el repo:** `packages/sim-entrada-eventos/aplication/Sim.usecases.ts:1` importa `OrderRepository` concreto de `sim-shared/infrastructure/`. Debería importar un `OrderRepository.port`.
|
||||
|
||||
**Cómo se ve:**
|
||||
|
||||
```typescript
|
||||
// ❌ Mal
|
||||
import { OrderRepository } from "sim-shared/infrastructure/OrderRepository.js";
|
||||
|
||||
export class SimUsecases {
|
||||
constructor(args: { orderRepository: OrderRepository }) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**Arreglo:** define el port en `sim-shared/domain/`:
|
||||
|
||||
```typescript
|
||||
// sim-shared/domain/OrderRepository.port.ts
|
||||
export interface OrderRepositoryPort {
|
||||
createOrder<T>(data: CreateOrderDTO): Promise<Result<string, OrderTracking<T>>>;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Y úsalo:
|
||||
|
||||
```typescript
|
||||
// ✅ Bien
|
||||
import { OrderRepositoryPort } from "sim-shared/domain/OrderRepository.port.js";
|
||||
|
||||
export class SimUsecases {
|
||||
constructor(args: { orderRepository: OrderRepositoryPort }) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
El `OrderRepository` concreto (en `sim-shared/infrastructure/`) implementa el port. El cableado en `config/` pasa la implementación concreta al constructor.
|
||||
|
||||
### Domain importa I/O
|
||||
|
||||
**Problema:** `domain/` importa `pg`, `amqplib`, `axios`, `express`, etc.
|
||||
|
||||
**Por qué importa:** el dominio debería poder ejecutarse en un test sin levantar infra. Si importa I/O, ya no puede.
|
||||
|
||||
**Arreglo:** mueve cualquier I/O a un adapter en `infrastructure/` y expón un port que el dominio use.
|
||||
|
||||
### Cruce de capa con import relativo largo
|
||||
|
||||
**Ejemplo:** `aplication/X.ts` que importa `../infrastructure/Y.ts`. Aunque "compila", está saltándose el port y acoplándose al adapter concreto.
|
||||
|
||||
**Arreglo:** usa el port. Si no existe, crea uno.
|
||||
|
||||
---
|
||||
|
||||
## DDD táctico
|
||||
|
||||
### Modelo anémico
|
||||
|
||||
**Problema:** entidades y value objects son "data bags" sin comportamiento. Toda la lógica vive en services o usecases.
|
||||
|
||||
**Cómo se ve:**
|
||||
|
||||
```typescript
|
||||
// ❌ Anémico
|
||||
class Order {
|
||||
id: number;
|
||||
status: OrderStatus;
|
||||
// sólo getters/setters
|
||||
}
|
||||
|
||||
// La lógica está afuera
|
||||
function canCancel(order: Order): boolean { /* ... */ }
|
||||
function cancel(order: Order): void { order.status = 'cancelled'; }
|
||||
```
|
||||
|
||||
**Arreglo:**
|
||||
|
||||
```typescript
|
||||
// ✅ Con comportamiento
|
||||
class Order {
|
||||
private constructor(private id: number, private status: OrderStatus) {}
|
||||
|
||||
cancel(): Result<string, void> {
|
||||
if (this.status === 'finished') return { error: "no se puede cancelar una order finalizada" };
|
||||
this.status = 'cancelled';
|
||||
return { data: undefined };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cuándo es OK ser anémico:** los DTOs de comando/query y los tipos puros de transferencia. Pero los agregados deberían tener invariantes y comportamiento.
|
||||
|
||||
### Excepción para flujo de negocio
|
||||
|
||||
**Problema:** lanzar `Error` para un caso esperado en vez de devolver `Result`.
|
||||
|
||||
**Ejemplo en el repo:** `domain/companies.ts:companyFromIccid` lanza si la compañía no existe. Eso fuerza `try/catch` en el controller y rompe el patrón `Result`.
|
||||
|
||||
**Arreglo:**
|
||||
|
||||
```typescript
|
||||
// ✅
|
||||
export function companyFromIccid(iccid: string): Result<string, string> {
|
||||
const code = iccid.slice(2, 6);
|
||||
const company = COMPANYICCID.get(code);
|
||||
if (company == undefined) return { error: `Compañía desconocida: ${code}` };
|
||||
return { data: company };
|
||||
}
|
||||
```
|
||||
|
||||
`throw` se reserva para invariantes rotas (bugs).
|
||||
|
||||
### Primitive obsession
|
||||
|
||||
**Problema:** representar dominio con tipos primitivos (`string`, `number`) en vez de Value Objects.
|
||||
|
||||
**Cómo se ve:** `iccid: string` por todos lados, sin garantía de que sea un ICCID válido.
|
||||
|
||||
**Arreglo:** crea un VO `Iccid` con factory que valida. Tradeoff: más boilerplate, pero el dominio gana garantías. Hazlo cuando la validación se repite o cuando el tipo se confunde con otros strings.
|
||||
|
||||
### Repository por entidad
|
||||
|
||||
**Problema:** una entidad-hijo dentro de un agregado tiene su propio repository, saltándose la raíz.
|
||||
|
||||
**Arreglo:** un repositorio por agregado, no por tabla. Operaciones a entidades-hijo van por la raíz.
|
||||
|
||||
---
|
||||
|
||||
## Hexagonal / ports & adapters
|
||||
|
||||
### Port en `infrastructure/`
|
||||
|
||||
**Problema:** la interfaz vive en infra. Los usecases tienen que importar de infra para conocer el contrato.
|
||||
|
||||
**Arreglo:** ports SIEMPRE en `domain/` (o `sim-shared/domain/`). Adapter en `infrastructure/`.
|
||||
|
||||
### Adapter sin port
|
||||
|
||||
**Problema:** una clase adapter sin interfaz declarada. Los usecases dependen directamente de la clase.
|
||||
|
||||
**Arreglo:** extrae la interfaz y haz que el usecase dependa de ella. Aunque sólo haya un adapter hoy, el port permite testear con mocks.
|
||||
|
||||
### `new` fuera del composition root
|
||||
|
||||
**Problema:** un usecase, controller o adapter hace `new OtraClase(...)` para resolver una dependencia.
|
||||
|
||||
**Arreglo:** todo cableado vive en `config/` o `index.ts`. Pasa la dependencia ya construida al constructor.
|
||||
|
||||
---
|
||||
|
||||
## EDA / RabbitMQ
|
||||
|
||||
### Publish que no espera confirmación real del broker
|
||||
|
||||
**Problema:** se llama `channel.publish(...)` y se asume éxito sin esperar el callback de confirm. El `Promise<{ success, error }>` que devuelve la implementación miente: marca `success` aunque el broker no haya aceptado el mensaje. Cualquier diseño de consistencia (outbox, retry, etc.) cae cuando la señal de "publicado" no es fiable.
|
||||
|
||||
**Cómo detectarlo:** revisa el `publish` del bus shared. Si después del `channel?.publish(...)` se hace `successEvents.push(event)` síncrono, sin esperar callback ni `waitForConfirms()`, hay bug.
|
||||
|
||||
**Ejemplo en el repo:** `sim-shared/infrastructure/RabbitMQEventBus.ts` — el canal se crea con `confirm: true` pero en `publish` se pushea a `successEvents` justo después de llamar a `channel.publish`, sin esperar el callback `(err, ok) => ...`. El bug es independiente del orden publish/save y enmascara cualquier solución de outbox: si el broker rechaza el mensaje, el `Promise` resolverá éxito igualmente.
|
||||
|
||||
**Arreglo:**
|
||||
|
||||
```typescript
|
||||
// ✅ Esperar la confirmación de verdad
|
||||
async publish(events: DomainEvent[]) {
|
||||
const successEvents: DomainEvent[] = [];
|
||||
const errorEvents: DomainEvent[] = [];
|
||||
for (const ev of events) {
|
||||
try {
|
||||
await new Promise<void>((res, rej) => {
|
||||
this.channel.publish(exchange, ev.key, content, opts, (err, ok) => {
|
||||
if (err) rej(err);
|
||||
else res();
|
||||
});
|
||||
});
|
||||
successEvents.push(ev);
|
||||
} catch (e) {
|
||||
errorEvents.push(ev);
|
||||
}
|
||||
}
|
||||
return { success: successEvents, error: errorEvents };
|
||||
}
|
||||
```
|
||||
|
||||
O usar `waitForConfirms()` del canal tras los publishes. La librería `amqp-connection-manager` tiene `ChannelWrapper.publish` que devuelve una `Promise` que SÍ espera confirms — usarla es el camino corto.
|
||||
|
||||
**Cuándo es bloqueante:** siempre que un consumer dependa de la señal "publicado" para tomar decisiones (outbox, marcar order como `sent`, devolver OK al cliente HTTP). El usuario que llamó a `/activation` recibe 200 cuando el broker quizá rechazó el mensaje.
|
||||
|
||||
### Publish + save no atómicos
|
||||
|
||||
**Problema:** se publica un evento y luego (o antes) se persiste un registro relacionado. Si una de las dos falla, hay inconsistencia silenciosa.
|
||||
|
||||
**Ejemplo en el repo:** los métodos de `SimUsecases` (`activation`, `cancelation`, etc.) hacen `eventBus.publish` seguido de `saveOrder`. Si el save falla, el evento ya está fuera. Si invirtiéramos el orden, podría fallar el publish con la order ya creada.
|
||||
|
||||
**Arreglo:** transactional outbox. Ver [EVENTS-RABBITMQ.md#outbox](EVENTS-RABBITMQ.md#outbox-y-atomicidad-publishsave).
|
||||
|
||||
**Cuándo es bloqueante:** cuando perder un evento implica pérdida de dinero o de operaciones que el cliente espera (activaciones, cancelaciones de pago).
|
||||
|
||||
### Consumer no idempotente
|
||||
|
||||
**Problema:** procesar el mismo mensaje dos veces tiene efectos distintos a procesarlo una.
|
||||
|
||||
**Cómo se ve:** un handler que hace INSERT en BDD sin chequear duplicados, o llama a una API externa sin clave de idempotencia.
|
||||
|
||||
**Arreglo:**
|
||||
|
||||
- Unique constraint en `correlation_id`.
|
||||
- Tabla `processed_messages` con INSERT antes de procesar.
|
||||
- O claves de idempotencia en la API externa (NOS, Objenious).
|
||||
|
||||
### Routing key fuera de patrón
|
||||
|
||||
**Problema:** publicar con `key: "activation_request"` o `key: "alai.activate"` (sin el prefijo `sim.`).
|
||||
|
||||
**Arreglo:** ajustarse a `sim.[compañia].[acción]`. Si necesitas otro patrón, justifícalo en el PR.
|
||||
|
||||
### Message sin `message_id`
|
||||
|
||||
**Problema:** se publica un evento sin `headers.message_id`. Pierdes trazabilidad.
|
||||
|
||||
**Arreglo:** SIEMPRE inyecta `message_id: uuidv7()` antes de publicar.
|
||||
|
||||
### `nack` con `requeue: true`
|
||||
|
||||
**Problema:** reentrega inmediata. Satura el broker y no respeta la política de delay/DLX.
|
||||
|
||||
**Arreglo:** usa el `nack` de `RabbitMQEventBus` que republica al delayed exchange con `x-retry-count++`.
|
||||
|
||||
### Worker que crea sus propios exchanges
|
||||
|
||||
**Problema:** un worker hace `assertExchange("sim.exchange", ...)` en su setup. Si las opciones no coinciden con el principal, RMQ falla.
|
||||
|
||||
**Arreglo:** los exchanges principales (`sim.exchange`, `sim.dlx`) los crea `RabbitMQEventBus.createChannel`. El servicio sólo crea SUS colas y bindings.
|
||||
|
||||
---
|
||||
|
||||
## CQRS
|
||||
|
||||
### Read y write mezclados en un usecase
|
||||
|
||||
**Problema:** un único `XUsecases` tiene métodos que escriben (commands) y métodos que leen para devolver datos al cliente (queries). Las queries escalan distinto, los commands tienen invariantes.
|
||||
|
||||
**Cómo se ve:** una clase con `createOrder()`, `cancelOrder()`, `getOrderById()`, `searchOrders()` mezclados.
|
||||
|
||||
**Arreglo (cuando duele):** divide en `XCommandUsecases` y `XQueryUsecases`. Mientras no duela, vivir con la mezcla es razonable; no fuerces CQRS si el read y write usan la misma BDD y los mismos datos.
|
||||
|
||||
### Read model no sincronizado o sin lag documentado
|
||||
|
||||
**Problema:** introduces un read model (proyección) sin definir cómo se sincroniza ni cuál es el lag aceptable.
|
||||
|
||||
**Arreglo:** documenta:
|
||||
- Mecanismo de sincronización (consumer del bus, replica de Postgres, materialized view, ...).
|
||||
- Lag esperado y máximo aceptable.
|
||||
- Comportamiento ante errores de proyección.
|
||||
|
||||
### CQRS prematuro
|
||||
|
||||
**Problema:** divides commands/queries en un servicio simple sin necesidad real, multiplicando el boilerplate.
|
||||
|
||||
**Arreglo:** empieza con un usecase mezclado. Divide cuando: (a) las queries necesitan un schema distinto, (b) el read se escala diferente, (c) hay tantas queries que ahogan al `XUsecases`.
|
||||
|
||||
---
|
||||
|
||||
## Persistencia
|
||||
|
||||
### Transacción cruzando agregados
|
||||
|
||||
**Problema:** una sola transacción modifica entidades de dos agregados distintos.
|
||||
|
||||
**Arreglo:** un agregado por transacción. La consistencia entre agregados se gana por eventos (eventual).
|
||||
|
||||
### Repositorio expone SQL crudo
|
||||
|
||||
**Problema:** el repositorio devuelve `QueryResult<Row>` o expone métodos genéricos `query(sql, params)`.
|
||||
|
||||
**Arreglo:** el repositorio devuelve entidades de dominio o DTOs de la capa de aplicación. SQL queda dentro.
|
||||
|
||||
### Falta de rollback en error
|
||||
|
||||
**Problema:** un repositorio hace `BEGIN` pero no `ROLLBACK` en algunos paths de error.
|
||||
|
||||
**Cómo se evita:** usa el patrón del repo:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
// ...
|
||||
if (algo.error) {
|
||||
await client.query("ROLLBACK");
|
||||
return algo;
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
```
|
||||
|
||||
O un wrapper `withTransaction(client, async (tx) => { ... })` que lo encapsule.
|
||||
|
||||
### Cliente PG sin `release`
|
||||
|
||||
**Problema:** `pgClient.connect()` devuelve un `PoolClient` que SIEMPRE debe hacer `release()`. Si no, fugas de conexión.
|
||||
|
||||
**Arreglo:** `try/finally` con `release()` o un wrapper que lo garantice.
|
||||
|
||||
---
|
||||
|
||||
## Manejo de errores
|
||||
|
||||
### Mezcla de `Result` y excepciones en la misma capa
|
||||
|
||||
**Problema:** algunos métodos de un usecase devuelven `Result`, otros tiran. El llamador no sabe qué hacer.
|
||||
|
||||
**Arreglo:** decide por capa. Aplicación devuelve `Result`. Dominio devuelve `Result` para reglas, lanza para invariantes rotas. Infra devuelve `Result` para errores de I/O esperables, lanza para configuración rota.
|
||||
|
||||
### `error: "string"` sin tipar
|
||||
|
||||
**Problema:** se usa `Result<string, T>` con strings libres. Imposible saber qué errores espera el llamador.
|
||||
|
||||
**Arreglo:** cuando la cantidad de errores se estabiliza, define un tipo unión: `Result<"NotFound" | "AlreadyExists" | "InvalidInput", T>`. Mientras evoluciona, strings está bien.
|
||||
|
||||
### `data?` accedido sin chequear `error`
|
||||
|
||||
**Problema:**
|
||||
|
||||
```typescript
|
||||
const r = await usecase.x();
|
||||
res.json({ id: r.data?.id }); // ❌ ignora el error
|
||||
```
|
||||
|
||||
**Arreglo:**
|
||||
|
||||
```typescript
|
||||
const r = await usecase.x();
|
||||
if (r.error != undefined) return res.status(500).json({ error: r.error });
|
||||
res.json({ id: r.data.id });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
### Test de aplicación que necesita Postgres
|
||||
|
||||
**Problema:** para testear un usecase tienes que levantar la BDD. Síntoma de que el usecase depende del adapter, no del port.
|
||||
|
||||
**Arreglo:** mockea el port. Si no puedes mockearlo porque no existe, crea el port y refactoriza.
|
||||
|
||||
### Test de dominio con mocks
|
||||
|
||||
**Problema:** el dominio puro tiene mocks. Eso significa que el dominio depende de algo de fuera. Mira los imports.
|
||||
|
||||
**Arreglo:** el dominio puro no debería necesitar mocks. Si necesitas, mueve la dependencia a un port y testéalo a nivel de aplicación.
|
||||
|
||||
### Solo hay tests de infra
|
||||
|
||||
**Problema:** todo está testeado integradamente, lento. La regresión de un cambio en aplicación se descubre por integration tests.
|
||||
|
||||
**Arreglo:** invierte la pirámide: muchos unitarios (dominio + aplicación con mocks), pocos integrados (infra crítica).
|
||||
@@ -0,0 +1,156 @@
|
||||
# Audit Checklist — sf-sim
|
||||
|
||||
Checklist exhaustiva para el Modo 2 (auditor) de la skill. Repásala antes de emitir el informe. Cada ítem debe poder citarse con `archivo:línea`.
|
||||
|
||||
Marca cada uno como:
|
||||
- ✅ OK
|
||||
- ⚠️ Observación (no bloqueante, pero hay que arreglarlo)
|
||||
- ❌ Bloqueante
|
||||
- N/A si no aplica al servicio
|
||||
|
||||
---
|
||||
|
||||
## 1. Estructura de carpetas
|
||||
|
||||
- [ ] Existen `domain/`, `aplication/`, `infrastructure/`, `config/` (o todas las que apliquen)
|
||||
- [ ] Cada archivo está en la capa correcta según su responsabilidad
|
||||
- [ ] No hay `aplication` vs `application` mezclados (convención del repo: `aplication`)
|
||||
- [ ] Tipos compartidos entre servicios viven en `sim-shared`, no duplicados
|
||||
- [ ] El servicio nuevo (si aplica) salió de `_template/`
|
||||
|
||||
## 2. Dependencias hacia dentro
|
||||
|
||||
- [ ] `domain/` no importa de `infrastructure/`, `config/`, ni librerías de I/O (`pg`, `amqplib`, `axios`, `express`)
|
||||
- [ ] `aplication/` depende de **ports** (interfaces en `domain/` o `sim-shared/domain`), no de adapters concretos
|
||||
- [ ] `infrastructure/` implementa ports declarados en `domain/` o `sim-shared`
|
||||
- [ ] `config/` cablea pero no contiene lógica
|
||||
- [ ] No hay imports cruzados con `..` que salten capas
|
||||
|
||||
## 3. Naming
|
||||
|
||||
- [ ] Ports tienen sufijo `.port.ts`
|
||||
- [ ] Usecases en `*.usecases.ts` con clase `XUsecases`
|
||||
- [ ] Controllers en `*.controller.ts`
|
||||
- [ ] Routers en `*.http.ts` o `*Routes.http.ts`
|
||||
- [ ] Repositorios en `*Repository.ts`
|
||||
- [ ] Eventos definidos en namespace (`SimEvents.activation`)
|
||||
- [ ] Tests con sufijo `.test.ts` al lado del archivo
|
||||
- [ ] Los nombres reflejan el dominio, no la tecnología (excepción: adapters)
|
||||
|
||||
## 4. DDD táctico
|
||||
|
||||
- [ ] Cada agregado tiene una raíz clara
|
||||
- [ ] Las invariantes del agregado se aplican dentro del agregado, no en services externos
|
||||
- [ ] Hay diferencia explícita entre Entity (con id) y Value Object (inmutable, igualdad por valor)
|
||||
- [ ] Los Value Objects son inmutables (no setters, factory que valida)
|
||||
- [ ] Los eventos de dominio están en `domain/` (locales) o `sim-shared/domain/`
|
||||
- [ ] No hay modelo anémico: las entidades tienen comportamiento
|
||||
- [ ] No se usa `throw` para flujo de negocio normal
|
||||
|
||||
## 5. Hexagonal — ports & adapters
|
||||
|
||||
- [ ] Cada dependencia externa tiene un port
|
||||
- [ ] Los ports están en `domain/` (o `sim-shared/domain`)
|
||||
- [ ] Cada adapter implementa explícitamente un port (`implements XPort`)
|
||||
- [ ] El composition root es único y vive en `config/` o `index.ts`
|
||||
- [ ] No hay `new` fuera del composition root para resolver dependencias
|
||||
- [ ] El constructor de cada usecase recibe `args: { ... }` con los ports tipados
|
||||
|
||||
## 6. EDA / RabbitMQ
|
||||
|
||||
- [ ] Eventos publicados están definidos como tipos en `SimEvents` o equivalente
|
||||
- [ ] Routing keys siguen `sim.[compañia].[acción]` (excepciones documentadas)
|
||||
- [ ] Cada evento publicado lleva `headers.message_id` con uuidv7
|
||||
- [ ] El bus se obtiene como `EventBus` (port), no como `RabbitMQEventBus` (concreto)
|
||||
- [ ] Las colas y bindings del servicio se declaran en su `buildStructure` (no en otro sitio)
|
||||
- [ ] No se redefinen `sim.exchange` ni `sim.dlx` con opciones distintas a las del shared
|
||||
- [ ] La política de retry y DLX está clara y documentada (`maxRetry`, qué errores van a delay vs DLX directo)
|
||||
- [ ] El handler del consumer es **idempotente** (verificable con duplicados)
|
||||
- [ ] Los `nack` van por el método del bus, no con `requeue: true` directo
|
||||
- [ ] El `consume` se hace después de la creación de la topología
|
||||
- [ ] El `publish` espera **confirmación real** del broker antes de devolver éxito (callback de confirm o `waitForConfirms`); revisa el bus shared. Si marca `success` sin esperar al broker, es bloqueante para cualquier diseño de outbox o de respuesta síncrona al cliente.
|
||||
|
||||
## 7. CQRS
|
||||
|
||||
- [ ] La separación command/query es proporcional a la complejidad del servicio (no se fuerza si no aporta)
|
||||
- [ ] Si hay read model, está documentado: mecanismo de sync, lag aceptable, manejo de fallos
|
||||
- [ ] Las queries no escriben (no tienen side effects en la BDD principal)
|
||||
- [ ] Los commands no devuelven datos calculados que requieran join con read model
|
||||
|
||||
## 8. Persistencia
|
||||
|
||||
- [ ] Un repositorio por agregado (no por tabla)
|
||||
- [ ] Los repositorios devuelven entidades de dominio o DTOs, no `QueryResult<Row>`
|
||||
- [ ] Las transacciones usan `BEGIN/COMMIT/ROLLBACK` con `try/finally` y `release()`
|
||||
- [ ] Ninguna transacción modifica entidades de dos agregados distintos
|
||||
- [ ] Migraciones en `deployment/database/migrations/` con versión consistente
|
||||
- [ ] `correlation_id` y `message_id` se persisten correctamente (uuidv7, unique donde proceda)
|
||||
- [ ] Si hay outbox, está implementado (no solo "publish + save" en serie)
|
||||
|
||||
## 9. Manejo de errores
|
||||
|
||||
- [ ] `Result<E, D>` se usa consistentemente en application y repositorios
|
||||
- [ ] Los errores se chequean con `if (r.error != undefined)`, no se accede a `r.data?` ignorando error
|
||||
- [ ] `tryCatch` se usa en los puntos de I/O directo
|
||||
- [ ] Los `throw` están reservados para invariantes/bugs, no flujo de negocio
|
||||
- [ ] Los controllers traducen `Result.error` a códigos HTTP apropiados (422 validación, 404 no encontrado, 5xx infra)
|
||||
|
||||
## 10. Logs y observabilidad
|
||||
|
||||
- [ ] Logs útiles en publish, consume, ack, nack, errores
|
||||
- [ ] `correlation_id` aparece en los logs para trazar flujo end-to-end
|
||||
- [ ] No hay logs ruidosos en hot paths sin razón
|
||||
- [ ] Errores se loguean con stacktrace cuando viene un `Error` real
|
||||
|
||||
## 11. Tests
|
||||
|
||||
- [ ] Hay tests unitarios de domain (sin mocks, sin async)
|
||||
- [ ] Hay tests de aplicación con ports mockeados
|
||||
- [ ] Hay tests de infraestructura para los adapters críticos
|
||||
- [ ] Los tests de aplicación corren sin levantar Postgres ni RabbitMQ
|
||||
- [ ] Los tests de adapter están en `*.test.ts` al lado del archivo
|
||||
- [ ] Los happy paths Y los paths de error están cubiertos
|
||||
- [ ] No hay tests con `assert(true)` o casos triviales que sólo cuentan línea
|
||||
|
||||
## 12. Configuración y env
|
||||
|
||||
- [ ] Las variables de entorno se acceden por un objeto central (`config/env/index.ts` o `env.ts`)
|
||||
- [ ] No hay `process.env.X` esparcidos por el código
|
||||
- [ ] El servicio falla rápido al arrancar si falta una env var crítica
|
||||
- [ ] Secretos no están commiteados (revisa `.env`, `test.env`)
|
||||
|
||||
## 13. ESM / TypeScript
|
||||
|
||||
- [ ] Imports tienen extensión `.js` aunque el archivo sea `.ts`
|
||||
- [ ] Los path aliases (`#config/*`, `#adapters/*`) están bien definidos en `tsconfig.json` y `package.json` `imports`
|
||||
- [ ] `tsc --noEmit` pasa
|
||||
- [ ] `tsconfig.json` extiende del `_template` o equivalente, no copia silvestre
|
||||
|
||||
## 14. Boot, health, puertos
|
||||
|
||||
- [ ] Hay endpoint `GET /health` que devuelve `{ status: "ok" }` con 200
|
||||
- [ ] El puerto del servicio está documentado en `README.md`
|
||||
- [ ] La conexión RMQ se establece antes (o concurrente, si HTTP no depende) de aceptar tráfico
|
||||
- [ ] El servicio reconecta a RMQ y a Postgres si la conexión cae
|
||||
|
||||
## 15. Documentación
|
||||
|
||||
- [ ] El servicio tiene un mini README o sección en el global explicando su responsabilidad
|
||||
- [ ] Los eventos que publica/consume están documentados
|
||||
- [ ] Las routing keys especiales (fuera de patrón) están justificadas
|
||||
- [ ] Los TODOs importantes están en código pero también referenciados en el README global
|
||||
|
||||
---
|
||||
|
||||
## Cierre del informe
|
||||
|
||||
Tras pasar la checklist:
|
||||
|
||||
1. **Veredicto general**: OK / OK con observaciones / Bloqueante.
|
||||
- Hay **al menos un ❌** → Bloqueante.
|
||||
- Solo ⚠️ → OK con observaciones.
|
||||
- Todo ✅ o N/A → OK.
|
||||
2. **Top 3 riesgos** ordenados por severidad.
|
||||
3. **Acciones recomendadas** concretas (archivo:línea + cambio sugerido).
|
||||
|
||||
No te limites a la checklist si encuentras algo no listado: añádelo en una sección "Otros hallazgos" al final del informe.
|
||||
232
.agents/skills/sf-backend-architecture/references/CODE-STYLE.md
Normal file
232
.agents/skills/sf-backend-architecture/references/CODE-STYLE.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Code Style — sf-sim / sim-eventos
|
||||
|
||||
Convenciones de código a bajo nivel (naming, idioma, preferencias TypeScript). Para arquitectura, capas, file layout y patrones de domain/application/infrastructure ver [`HOUSE-STYLE.md`](./HOUSE-STYLE.md).
|
||||
|
||||
## Aplicación de las reglas
|
||||
|
||||
**Estricto en código nuevo. Oportunista al tocar.** Cuando edites un fichero existente, alinea lo que tocas con estas reglas. No abras PRs masivos de retrofit. La excepción: el typo `aplication/` se mantiene tal cual hasta que se decida cambiar todos los path aliases del monorepo a la vez.
|
||||
|
||||
`sim-objenious-cron/` está exento: sus desvíos son intencionales por la API que consume. No auditar este servicio contra estas reglas salvo petición explícita.
|
||||
|
||||
## Tabla de contenidos
|
||||
|
||||
1. [Idioma](#idioma)
|
||||
2. [Naming](#naming)
|
||||
3. [TypeScript](#typescript)
|
||||
4. [Async](#async)
|
||||
5. [Errores](#errores)
|
||||
6. [Tests — estilo de redacción](#tests--estilo-de-redacción)
|
||||
|
||||
---
|
||||
|
||||
## Idioma
|
||||
|
||||
### Comentarios
|
||||
|
||||
**Español, siempre.** Inline, JSDoc, TODO. Sin excepciones.
|
||||
|
||||
```typescript
|
||||
// ✅ Validamos el ICCID antes de encolar el evento
|
||||
// ❌ Validate ICCID before enqueueing the event
|
||||
```
|
||||
|
||||
### Identificadores
|
||||
|
||||
Convenios por capa:
|
||||
|
||||
| Capa | Idioma | Razón |
|
||||
|---|---|---|
|
||||
| Dominio (entidades, value objects, eventos, ports de negocio) | Español | Lenguaje ubicuo del negocio (`iccid`, `lineas`, `operacion`, `activacion`) |
|
||||
| Infraestructura técnica (HTTP, DB, RMQ, JWT, helpers) | Inglés | Vocabulario estándar de la plataforma (`httpClient`, `eventBus`, `repository`, `tryCatch`) |
|
||||
|
||||
```typescript
|
||||
// ✅ Dominio en español
|
||||
class ActivacionUsecases { activarLinea(iccid: string) { ... } }
|
||||
|
||||
// ✅ Infraestructura en inglés
|
||||
class NosHttpClient { async post(url: string, body: unknown) { ... } }
|
||||
|
||||
// ❌ Mezcla dentro del mismo concepto
|
||||
class ActivacionUsecases { activateLine(iccid: string) { ... } }
|
||||
```
|
||||
|
||||
**Caso especial:** columnas de DB en `snake_case` (refleja la convención PostgreSQL). En el código TS, los campos del row mantienen el `snake_case` solo en el adapter del repositorio; se mapean a camelCase al cruzar a dominio.
|
||||
|
||||
```typescript
|
||||
// ✅ En el adapter
|
||||
const row: { correlation_id: string; iccid: string } = await pg.query(...);
|
||||
return { correlationId: row.correlation_id, iccid: row.iccid };
|
||||
```
|
||||
|
||||
## Naming
|
||||
|
||||
### Funciones y métodos
|
||||
|
||||
`camelCase`, verbo en infinitivo. No usar prefijos tipo `do*`, `handle*`, `process*` salvo que el verbo aporte (handler real).
|
||||
|
||||
```typescript
|
||||
// ✅
|
||||
getUsuario(id), activarLinea(iccid), publishEvent(evt), tryCatch(promise)
|
||||
|
||||
// ❌
|
||||
get_usuario(id), DoActivation(iccid), processEventStuff(evt)
|
||||
```
|
||||
|
||||
### Variables y constantes
|
||||
|
||||
| Tipo | Patrón | Ejemplo |
|
||||
|---|---|---|
|
||||
| Variables locales y parámetros | `camelCase` | `activationDate`, `msgData` |
|
||||
| Constantes de módulo (valores literales fijos) | `UPPER_SNAKE_CASE` | `DEFAULT_LIMIT = 1000`, `OPERATION_URL = "/actions/..."` |
|
||||
| Constantes que apuntan a objetos / instancias inyectables | `camelCase` | `const eventBus = new RabbitMQEventBus(...)` |
|
||||
|
||||
Una constante es `UPPER_SNAKE` solo si su valor es literal e inmutable conceptualmente (número, string de configuración, URL). Una instancia configurada (`eventBus`, `pgClient`) va en `camelCase` aunque sea `const`.
|
||||
|
||||
### Clases, interfaces y tipos
|
||||
|
||||
- `PascalCase` para clases, interfaces, tipos.
|
||||
- **Sin prefijo `I`** en interfaces. Es ruido — el editor ya distingue `interface` de `class`.
|
||||
|
||||
```typescript
|
||||
// ✅
|
||||
interface EventBus { ... }
|
||||
type OrderStatus = 'pending' | 'running' | 'finished';
|
||||
|
||||
// ❌
|
||||
interface IEventBus { ... }
|
||||
```
|
||||
|
||||
### Sufijos de clase
|
||||
|
||||
Ya cubiertos en `HOUSE-STYLE.md` § Naming: `*Usecases`, `*Controller`, `*Repository`, `*HttpClient`, `*Service`. Resumen aquí solo para no dejar duda:
|
||||
|
||||
- **Singular** salvo cuando agrupa varios casos: `Sim.usecases.ts → SimUsecases` (varios métodos de orquestación). `OrderRepository` (singular, una agregada).
|
||||
- Para **código nuevo, prefiere singular**: un caso de uso por fichero (`activarLinea.usecase.ts → ActivarLineaUsecase`). Solo agrupa en plural si hay justificación (varios casos de uso del mismo agregado que comparten dependencias).
|
||||
|
||||
### Ficheros
|
||||
|
||||
Sufijos del repo (extracto de HOUSE-STYLE):
|
||||
- Clases principales: `Concepto.controller.ts`, `Concepto.usecases.ts`, `Concepto.service.ts`
|
||||
- Ports: `Algo.port.ts`
|
||||
- Routers Express: `algoRoutes.http.ts`
|
||||
- Repositorios y clientes: `AlgoRepository.ts`, `AlgoHttpClient.ts`
|
||||
- Tests: `Algo.test.ts` (NUNCA `.spec.ts`)
|
||||
- Utils: `algoHelpers.ts` (camelCase, sin sufijo de capa)
|
||||
|
||||
**Una clase exportada por fichero.** Helpers privados pueden convivir.
|
||||
|
||||
## TypeScript
|
||||
|
||||
### `interface` vs `type`
|
||||
|
||||
| Usa `interface` para | Usa `type` para |
|
||||
|---|---|
|
||||
| Ports / contratos extensibles | DTOs, shapes de datos |
|
||||
| Cuando esperas que se implemente con `implements` | Uniones discriminadas, intersecciones |
|
||||
| | Alias de tipos primitivos o utilitarios |
|
||||
|
||||
Por defecto, **`type`**. Solo `interface` si es un puerto o contrato implementado por adapters.
|
||||
|
||||
```typescript
|
||||
// ✅ Port → interface
|
||||
interface OperationsRepository { save(op: Operation): Promise<Result<Error, void>> }
|
||||
|
||||
// ✅ DTO / shape → type
|
||||
type ActivationCommand = { iccid: string; companyId: string };
|
||||
|
||||
// ✅ Unión discriminada → type
|
||||
type OrderStatus = 'pending' | 'running' | 'finished' | 'failed' | 'dlx';
|
||||
```
|
||||
|
||||
### Enums
|
||||
|
||||
**No usar `enum`.** Sustitúyelos por uniones de strings literales.
|
||||
|
||||
```typescript
|
||||
// ✅
|
||||
type OrderStatus = 'pending' | 'running' | 'finished';
|
||||
|
||||
// ❌
|
||||
enum OrderStatus { Pending, Running, Finished }
|
||||
```
|
||||
|
||||
Razón: los enums TS tienen runtime cost y semántica confusa con números/strings. Una unión de strings es más simple, type-safe, y serializa directamente a JSON.
|
||||
|
||||
### `any` y `unknown`
|
||||
|
||||
**Prohibido `any` en código nuevo** salvo en boundaries de I/O externo donde el tipado real no es viable, y siempre con comentario de justificación. Preferencia: `unknown` + type guard.
|
||||
|
||||
```typescript
|
||||
// ✅ Boundary justificado
|
||||
const body = JSON.parse(raw) as unknown; // input externo sin esquema
|
||||
if (!isActivationCommand(body)) return { error: new Error('payload inválido') };
|
||||
|
||||
// ✅ any tolerado en boundary con comentario
|
||||
const row: any = await pg.query(...); // pg devuelve any por defecto, lo mapeamos abajo
|
||||
|
||||
// ❌ any en código de aplicación o dominio
|
||||
function calcular(input: any) { ... }
|
||||
```
|
||||
|
||||
### `readonly`
|
||||
|
||||
Marca como `readonly` todas las dependencias inyectadas por constructor.
|
||||
|
||||
```typescript
|
||||
// ✅
|
||||
class SimUsecases {
|
||||
constructor(private readonly args: { eventBus: EventBus; repo: OperationsRepository }) {}
|
||||
}
|
||||
```
|
||||
|
||||
Para propiedades de tipos de dominio inmutables, también `readonly` cuando aporte (eventos, value objects).
|
||||
|
||||
### Generics
|
||||
|
||||
Uso libre y bien adoptado en el repo (`Result<E, D>`, `OrderTracking<T>`). Convención de nombres:
|
||||
|
||||
- `T`, `U`, `V` para tipos genéricos sin restricción semántica clara.
|
||||
- Nombre descriptivo si aporta: `Result<E, D>` (Error, Data), `EventBus` no necesita generic.
|
||||
|
||||
## Async
|
||||
|
||||
**`async/await` siempre.** No encadenar `.then().catch()` salvo fire-and-forget consciente (con comentario explicando por qué).
|
||||
|
||||
```typescript
|
||||
// ✅
|
||||
const result = await usecase.activacion(args);
|
||||
if (result.error != undefined) return { error: result.error };
|
||||
|
||||
// ❌
|
||||
usecase.activacion(args).then(r => ...).catch(e => ...);
|
||||
|
||||
// ⚠️ Fire-and-forget aceptable con comentario
|
||||
// No esperamos: el cron solo dispara la verificación, el resultado se persiste en BD
|
||||
checkRequest(id).catch(e => logger.error(e));
|
||||
```
|
||||
|
||||
## Errores
|
||||
|
||||
Cubierto en `HOUSE-STYLE.md` § Result. Resumen:
|
||||
|
||||
- Errores esperables (negocio, I/O) → `Result<E, D>`.
|
||||
- Invariantes / bugs imposibles → `throw` o `assert`.
|
||||
- **No** crear clases de error custom salvo necesidad real (no hay ninguna en el repo, no la añadas sin razón).
|
||||
|
||||
## Tests — estilo de redacción
|
||||
|
||||
`describe` y `it` ambos en español.
|
||||
|
||||
```typescript
|
||||
// ✅
|
||||
describe('SimUsecases', () => {
|
||||
it('debería rechazar la activación si el ICCID no es válido', () => { ... });
|
||||
});
|
||||
|
||||
// ❌
|
||||
describe('SimUsecases', () => {
|
||||
it('should reject activation when ICCID is invalid', () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
Corolario: cualquier test nuevo o tocado se redacta en español. Tests existentes en inglés se traducen al editarlos, no en bloque.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Events & RabbitMQ — sf-sim
|
||||
|
||||
Cómo se diseñan, publican y consumen los eventos en el monorepo. Lee esto antes de tocar `eventBus`, exchanges, colas, retries o cuando diseñes un nuevo evento.
|
||||
|
||||
## Tabla de contenidos
|
||||
|
||||
1. [Tipo `DomainEvent`](#tipo-domainevent)
|
||||
2. [Routing keys](#routing-keys)
|
||||
3. [Topología de RabbitMQ](#topología-de-rabbitmq)
|
||||
4. [Publicar](#publicar)
|
||||
5. [Consumir](#consumir)
|
||||
6. [Reintentos y DLX](#reintentos-y-dlx)
|
||||
7. [Idempotencia](#idempotencia)
|
||||
8. [Outbox y atomicidad publish+save](#outbox-y-atomicidad-publishsave)
|
||||
9. [Versionado de eventos](#versionado-de-eventos)
|
||||
10. [Tipos de evento del repo](#tipos-de-evento-del-repo)
|
||||
|
||||
---
|
||||
|
||||
## Tipo `DomainEvent`
|
||||
|
||||
`packages/sim-shared/domain/DomainEvent.ts`:
|
||||
|
||||
```typescript
|
||||
export type DomainEvent = {
|
||||
key: string, // routing key
|
||||
payload: object, // datos del evento
|
||||
headers?: object & {
|
||||
message_id?: string // uuidv7, idempotencia
|
||||
},
|
||||
occurredOn?: Date,
|
||||
}
|
||||
```
|
||||
|
||||
**Reglas:**
|
||||
|
||||
- `key`: routing key, NO es el nombre del evento humano. Sigue el patrón de routing (`sim.[compañia].[acción]`).
|
||||
- `payload`: datos mínimos para que el consumidor haga su trabajo. NO meter info derivable.
|
||||
- `headers.message_id`: SIEMPRE rellenarlo al publicar (uuidv7). Liga el evento a una `Order`.
|
||||
- `occurredOn`: opcional pero recomendable. Útil para auditoría y para detectar mensajes viejos.
|
||||
|
||||
**Eventos típicos extendidos** (`SimEvents` namespace en `sim-shared/domain/SimEvents.ts`):
|
||||
|
||||
```typescript
|
||||
export namespace SimEvents {
|
||||
export type activation = DomainEvent & {
|
||||
key: `sim.${string}.activate`,
|
||||
payload: { iccid: string, offer?: string },
|
||||
}
|
||||
export type cancel = DomainEvent & {
|
||||
key: `sim.${string}.cancel`,
|
||||
payload: { iccid: string },
|
||||
options: {}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Los template literals en `key` te dan **autocompletado y errores de compilación** si te equivocas con la routing key. Aprovéchalo: define el tipo del evento ANTES de empezar a publicar.
|
||||
|
||||
## Routing keys
|
||||
|
||||
**Patrón:** `sim.[compañia].[acción]`
|
||||
|
||||
- `compañia`: `alai` | `nos` | `objenious` (del Map en `domain/companies.ts`)
|
||||
- `acción`: `activate` | `preActivate` | `reactivate` | `cancel` | `pause` | `free` | `save` | `test` | `unknown`
|
||||
|
||||
Ejemplos válidos: `sim.alai.activate`, `sim.nos.cancel`, `sim.objenious.pause`.
|
||||
|
||||
**Por qué este patrón:** permite a cada worker hacer binding por compañía con `sim.[compañia].*` y filtrar por acción si quiere con `sim.*.activate`. Cambiar este patrón rompe los bindings de los workers existentes.
|
||||
|
||||
**Si necesitas un nuevo nivel** (`sim.[compañia].[acción].[sub]`):
|
||||
1. Justifica por qué no cabe en payload o headers
|
||||
2. Documenta en el README
|
||||
3. Comprueba que los bindings actuales (`sim.[compañia].*`) siguen capturando o ajusta los workers
|
||||
|
||||
## Topología de RabbitMQ
|
||||
|
||||
```text
|
||||
┌─────────────────┐
|
||||
│ sim.exchange │ ← topic, exchange principal (DURABLE)
|
||||
│ (publish) │
|
||||
└────────┬────────┘
|
||||
│ binding sim.[compañia].*
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ q.<servicio>.input │ ← cola del consumidor
|
||||
└──────────┬─────────┘
|
||||
│ NACK
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ <servicio>.delayed │ ← delay exchange por servicio
|
||||
└──────────┬──────────┘
|
||||
│ tras N segundos
|
||||
▼ (republish a sim.exchange con x-retry-count++)
|
||||
(back al inicio)
|
||||
│
|
||||
│ tras maxRetry
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ sim.dlx │ ← exchange dead-letter
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
- **Exchange principal**: `sim.exchange`, tipo `topic`, durable.
|
||||
- **DLX**: `sim.dlx`, topic, durable. Cola unica de inspección manual.
|
||||
- **Delayed**: cada servicio define su propio delayed exchange en su `eventBusConfig.ts`.
|
||||
- **Bindings**: cada worker hace binding desde `sim.exchange` con la routing key que le toca.
|
||||
|
||||
**Estructura inicial garantizada:** `RabbitMQEventBus.createChannel` asegura que `sim.exchange` y `sim.dlx` existen al conectar. Las colas y bindings propios los crea cada servicio en su `buildStructure` callback.
|
||||
|
||||
## Publicar
|
||||
|
||||
```typescript
|
||||
// 1. Construye el evento con tipo
|
||||
const ev = <SimEvents.activation>{
|
||||
key: `sim.${compañia}.activate`,
|
||||
payload: { iccid, offer },
|
||||
}
|
||||
|
||||
// 2. Mete message_id (uuidv7) en headers
|
||||
const evWithId = {
|
||||
...ev,
|
||||
headers: { ...ev.headers, message_id: uuidv7() }
|
||||
}
|
||||
|
||||
// 3. Publica
|
||||
const result = await this.eventBus.publish([evWithId]);
|
||||
```
|
||||
|
||||
**Reglas:**
|
||||
|
||||
- Mete `message_id` ANTES de publicar. Si lo metes después, has perdido la trazabilidad si el publish falla.
|
||||
- `publish` recibe un array. Aprovecha si tienes que mandar varios.
|
||||
- Si el caso de uso debe persistir además del publish, lee la sección [Outbox y atomicidad](#outbox-y-atomicidad-publishsave).
|
||||
|
||||
## Consumir
|
||||
|
||||
```typescript
|
||||
this.eventBus.consume(QUEUE_NAME, async (msg) => {
|
||||
if (msg == null) return;
|
||||
try {
|
||||
const event = JSON.parse(msg.content.toString());
|
||||
const result = await this.usecase.handle(event);
|
||||
if (result.error != undefined) {
|
||||
console.error("[!] Handler error", result.error);
|
||||
return this.eventBus.nack(msg); // delay → retry → DLX
|
||||
}
|
||||
return this.eventBus.ack(msg);
|
||||
} catch (e) {
|
||||
console.error("[!] Excepción inesperada", e);
|
||||
return this.eventBus.nack(msg);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Reglas:**
|
||||
|
||||
- Si el handler devuelve error de negocio recuperable → `nack` (entra a delay).
|
||||
- Si lanza excepción inesperada → `nack` (asume que es transitoria; si no lo es, el `maxRetry` la moverá a DLX).
|
||||
- NO uses `nack(msg, false, true)` (requeue inmediato): satura el broker y no permite el delay. La implementación actual hace publish manual al delayed exchange.
|
||||
|
||||
## Reintentos y DLX
|
||||
|
||||
**Configuración global:** en `sim-shared/config` (a definir; actualmente cada servicio decide).
|
||||
|
||||
**Per-mensaje:** header `x-retry-count`. El bus lo incrementa en cada `nack`. Cuando supera `maxRetry`, en vez de delay republica al `dlxExchange`.
|
||||
|
||||
**Política recomendada:**
|
||||
|
||||
| Tipo de error | Acción |
|
||||
|---|---|
|
||||
| Red / 5xx externo / timeout | `nack` (delay + retry) |
|
||||
| 4xx del proveedor con código de "imposible" | publish directo a DLX |
|
||||
| Error de validación del payload | publish directo a DLX (no se va a recuperar) |
|
||||
| Error de BDD que parece transitorio | `nack` |
|
||||
| Error desconocido | `nack`, dejar que `maxRetry` decida |
|
||||
|
||||
**Inspección de DLX:** la cola DLX es de procesamiento manual. Después de N reintentos asumimos que un humano debe mirar.
|
||||
|
||||
**Auditoría:** un servicio debe documentar su `maxRetry` y su política, no asumirla del shared.
|
||||
|
||||
## Idempotencia
|
||||
|
||||
Un consumidor PUEDE recibir el mismo mensaje dos veces (reentregas, restart del consumer, retries). Diseña los handlers asumiéndolo.
|
||||
|
||||
**Mecanismos:**
|
||||
|
||||
1. **Por `correlation_id` / `message_id`**: la `Order` en BDD tiene unique sobre `correlation_id`. Si intentas crear una con el mismo, devuelve la existente sin escribir.
|
||||
2. **Por estado del agregado**: si la operación es "activar", y el agregado ya está activo, no-op idempotente.
|
||||
3. **Outbox de procesados**: tabla `processed_messages(message_id)` con unique. INSERT antes de procesar; si conflict, ya estaba.
|
||||
|
||||
**Anti-patrón:** asumir "exactly once". RabbitMQ no lo garantiza. Diseña para "at least once" + idempotencia.
|
||||
|
||||
## Outbox y atomicidad publish+save
|
||||
|
||||
**El problema actual:** en varios usecases del repo se hace:
|
||||
|
||||
```typescript
|
||||
await this.eventBus.publish([eventWithId]); // 1
|
||||
await this.saveOrder(eventWithId); // 2
|
||||
```
|
||||
|
||||
Si (1) ok y (2) falla → evento publicado sin order que lo trackee.
|
||||
Si invertimos el orden, mismo problema en sentido contrario.
|
||||
|
||||
**Solución (transactional outbox):**
|
||||
|
||||
1. En la misma transacción que escribe el agregado, INSERT en una tabla `outbox(id, payload, status='pending')`.
|
||||
2. Un publisher (cron, worker, listener postgres) lee `outbox` y publica al bus.
|
||||
3. Cuando el publish confirma, marca `outbox.status = 'sent'`.
|
||||
|
||||
**Tradeoff:** añade un componente publisher. Para el repo actual, en muchos casos la consecuencia de la inconsistencia es asumible (la `Order` se reconcilia con el resultado del worker). Documenta explícitamente cuándo aceptas la ventana y cuándo NO.
|
||||
|
||||
**Cuándo es bloqueante:** cuando un evento perdido implica pérdida de datos o de dinero (activaciones que el cliente paga, p.ej.). En esos casos, outbox es obligatorio.
|
||||
|
||||
## Versionado de eventos
|
||||
|
||||
**Aún no resuelto en el repo (TODO en README).** Recomendación:
|
||||
|
||||
1. Añadir versión al payload o en headers (`headers.version: 1`).
|
||||
2. NUNCA romper compatibilidad de un evento ya publicado: añade campos opcionales, no quites campos.
|
||||
3. Si necesitas un cambio incompatible, crea un `sim.[compañia].[acción].v2` y migra consumers gradualmente.
|
||||
|
||||
## Tipos de evento del repo
|
||||
|
||||
Definidos en `sim-shared/domain/SimEvents.ts`:
|
||||
|
||||
| Tipo | Routing key | Payload | Notas |
|
||||
|---|---|---|---|
|
||||
| `general` | `sim.*.*` | `{ iccid }` | Tipo genérico, evita usarlo si tienes uno específico |
|
||||
| `activation` | `sim.${compañia}.activate` | `{ iccid, offer? }` | |
|
||||
| `preActivation` | `sim.${compañia}.preActivate` | `{ iccid }` | |
|
||||
| `reActivation` | `sim.${compañia}.reactivate` | `{ iccid }` | |
|
||||
| `cancel` | `sim.${compañia}.cancel` | `{ iccid }` | Alias `terminate` para Objenious |
|
||||
| `pause` | `sim.${compañia}.pause` | `{ iccid }` | Alias `suspend` |
|
||||
| `free` | `sim.${compañia}.free` | `{ iccid }` | |
|
||||
| `save` | `sim.${compañia}.save` | `{ iccid, imei }` | |
|
||||
|
||||
Si añades un nuevo tipo: defínelo aquí, expórtalo desde `SimEvents`, documenta routing key y payload, y avisa de qué workers van a recibirlo.
|
||||
183
.agents/skills/sf-backend-architecture/references/HOUSE-STYLE.md
Normal file
183
.agents/skills/sf-backend-architecture/references/HOUSE-STYLE.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# House Style — sf-sim / sim-eventos
|
||||
|
||||
Convenciones de **arquitectura** del repo (capas, ports, composition root, ESM, tests). Para convenciones de **código a bajo nivel** (naming, idioma, `interface`/`type`, política de `any`, async, redacción de tests) ver [`CODE-STYLE.md`](./CODE-STYLE.md).
|
||||
|
||||
Si algo no aparece en ninguno de los dos, asume las defaults razonables de Hexagonal/DDD.
|
||||
|
||||
## Tabla de contenidos
|
||||
|
||||
1. [Estructura de carpetas](#estructura-de-carpetas)
|
||||
2. [Naming](#naming)
|
||||
3. [Inyección de dependencias](#inyección-de-dependencias)
|
||||
4. [Result\<E,D\>](#resulted)
|
||||
5. [ESM y path aliases](#esm-y-path-aliases)
|
||||
6. [Composition root](#composition-root)
|
||||
7. [Tipos compartidos vs locales](#tipos-compartidos-vs-locales)
|
||||
8. [Health, puertos y boot](#health-puertos-y-boot)
|
||||
9. [Tests](#tests)
|
||||
|
||||
---
|
||||
|
||||
## Estructura de carpetas
|
||||
|
||||
```text
|
||||
packages/
|
||||
├── _template/ # Plantilla para crear servicios nuevos
|
||||
├── sim-shared/ # Tipos, ports, adapters compartidos
|
||||
│ ├── domain/ # DomainEvent, EventBus.port, Result, Order, SimEvents...
|
||||
│ ├── aplication/ # Helpers transversales (BodyValidator, JWT.service)
|
||||
│ ├── infrastructure/ # RabbitMQEventBus, OrderRepository, PgClient, HTTPClient
|
||||
│ ├── ports/ # Ports compartidos adicionales (queues/AMQPclient.port)
|
||||
│ └── config/ # jwtService.config, ...
|
||||
├── sim-entrada-eventos/ # Gateway HTTP → publish a RMQ
|
||||
│ ├── domain/ # Reglas y tipos LOCALES del gateway (companies.ts)
|
||||
│ ├── aplication/ # Sim.usecases, Sim.controller, Order.usecases, validators
|
||||
│ ├── infrastructure/ # *Routes.http.ts — Express routers
|
||||
│ └── config/ # eventBusConfig, postgreConfig, env/
|
||||
├── sim-consumidor-nos/ # Worker NOS
|
||||
├── sim-consumidor-objenious/ # Worker Objenious
|
||||
└── sim-objenious-cron/ # Cron de seguimiento Objenious
|
||||
```
|
||||
|
||||
**Nota intencional:** `aplication` con "p" simple. Es la convención del repo. Mantenla. Si la cambias, cambia todos los path aliases del monorepo.
|
||||
|
||||
**Una carpeta por capa, no por feature.** Dentro de cada capa los archivos se nombran por concepto (ver Naming).
|
||||
|
||||
## Naming
|
||||
|
||||
| Tipo | Patrón | Ejemplos |
|
||||
|---|---|---|
|
||||
| Port (interface o type) | `*.port.ts` | `EventBus.port.ts`, `operationsRepository.port.ts`, `AMQPclient.port.ts` |
|
||||
| Use case (clase orquestadora) | `*.usecases.ts` con clase `XUsecases` | `Sim.usecases.ts` → `class SimUsecases` |
|
||||
| Controller HTTP | `*.controller.ts` | `Sim.controller.ts` |
|
||||
| Router Express | `*.http.ts` o `*Routes.http.ts` | `simRoutes.http.ts`, `franceRoutes.http.ts` |
|
||||
| Repositorio (adapter) | `*Repository.ts` | `OrderRepository.ts`, `NosRepository.ts` |
|
||||
| Cliente HTTP externo | `*HttpClient.ts` o `*Client.ts` | `NosHttpClient.ts` |
|
||||
| Servicio externo | `*Service.ts` | `NosJwtService.ts` |
|
||||
| Validators | `httpValidators.ts` | (uno por servicio) |
|
||||
| Eventos de dominio | `*Events.ts` con namespace | `SimEvents.activation`, `SimEvents.cancel` |
|
||||
| Tests | mismo nombre + `.test.ts` | `OrderRepository.test.ts` |
|
||||
| Config | `X.config.ts` o `XConfig.ts` (mezclado) | `eventBusConfig.ts`, `jwtService.config.ts` |
|
||||
| Env | `config/env/index.ts` o `config/env/env.ts` | acceso central a `process.env` |
|
||||
|
||||
**Reglas:**
|
||||
|
||||
- Ports SIEMPRE en `domain/` (o `sim-shared/domain` si compartido), nunca en `infrastructure/`.
|
||||
- Adapters SIEMPRE en `infrastructure/`.
|
||||
- Usecases en `aplication/`.
|
||||
- DTOs de comando/query en `aplication/`. Tipos de dominio en `domain/`.
|
||||
|
||||
## Inyección de dependencias
|
||||
|
||||
**Manual, sin framework.** Constructor con objeto `args: { dep1, dep2, ... }`. Ver el ejemplo concreto y el contraste positional vs args en `SKILL.md` → "Patrones obligatorios en código".
|
||||
|
||||
**Lo crítico:** los tipos del constructor apuntan al **port**, no al adapter concreto. Si `SimUsecases` recibe `OrderRepository` (clase concreta), es violación. Pasa por `OrderRepositoryPort`.
|
||||
|
||||
## Result\<E,D\>
|
||||
|
||||
`packages/sim-shared/domain/Result.ts`:
|
||||
|
||||
```typescript
|
||||
export type Success<D> = { error?: undefined | null, data: D }
|
||||
export type Failure<E = Error> = { data?: undefined | null, error: E }
|
||||
export type Result<E, D> = Failure<E> | Success<D>
|
||||
```
|
||||
|
||||
**Cuándo usarlo:**
|
||||
|
||||
- Toda función de aplicación o repositorio que pueda fallar por motivos de negocio o I/O esperable → `Result<string, T>` o `Result<Error, T>`.
|
||||
- Las funciones de dominio puras pueden devolver `T` directo si no fallan; si fallan por reglas de negocio, `Result`.
|
||||
|
||||
**Cuándo NO usarlo:**
|
||||
|
||||
- Bugs / invariantes rotas → `assert` o `throw`. Esto es para errores que no deberían poder ocurrir.
|
||||
|
||||
**Patrón de chequeo:**
|
||||
|
||||
```typescript
|
||||
const r = await usecase.activation(args);
|
||||
if (r.error != undefined) {
|
||||
// ramificación de error
|
||||
return { error: r.error };
|
||||
}
|
||||
// aquí r.data está garantizado
|
||||
```
|
||||
|
||||
**Helper:** `tryCatch(promise)` envuelve cualquier promesa y devuelve `Result<Error, T>`.
|
||||
|
||||
**Por qué Result y no excepciones:** las excepciones se propagan silenciosamente, hacen que los tipos mientan ("devuelve `T`" pero realmente puede explotar). `Result` te obliga a manejar el caso de error en el sitio donde ocurre. Es coste cognitivo al escribir, pero ahorra horas de debug.
|
||||
|
||||
## ESM y path aliases
|
||||
|
||||
- Proyecto en ESM puro. Imports con extensión `.js` aunque el archivo sea `.ts`. TypeScript lo permite.
|
||||
- Yarn workspaces: `sim-shared` se importa como `sim-shared/...` desde cualquier servicio.
|
||||
- Path aliases por servicio (en `tsconfig.json` y `package.json` `imports`):
|
||||
- `#config/*` → `./config/*`
|
||||
- `#adapters/*` → `./infrastructure/*`
|
||||
- (revisar el `tsconfig` de cada servicio porque varían)
|
||||
|
||||
**Ejemplo correcto:**
|
||||
|
||||
```typescript
|
||||
import { rabbitmqEventBus } from '#config/eventBusConfig.js';
|
||||
import { simRoutes } from "./infrastructure/simRoutes.http.js"
|
||||
import { EventBus } from "sim-shared/domain/EventBus.port.js";
|
||||
```
|
||||
|
||||
**Errores típicos:**
|
||||
|
||||
- Olvidar el `.js` → fallo en runtime de Node.
|
||||
- Importar de `sim-shared/...` con ruta relativa larga → usa el alias de workspace.
|
||||
- Importar archivo con `..` cruzando capas (e.g. `aplication/X` importa `../infrastructure/Y` saltándose el port).
|
||||
|
||||
## Composition root
|
||||
|
||||
El cableado vive en:
|
||||
|
||||
1. `config/eventBusConfig.ts` — instancia el bus con la conexión RMQ
|
||||
2. `config/postgreConfig.ts` — instancia el `PgClient`
|
||||
3. `index.ts` — instancia repositorios → usecases → controllers → routers → arranca Express y conecta el bus
|
||||
|
||||
**Regla:** todo `new X(...)` que cablee dependencias debe vivir en `config/` o `index.ts`. NUNCA dentro de un usecase, controller, ni adapter. Si encuentras un `new` fuera de ahí, es un olor.
|
||||
|
||||
## Tipos compartidos vs locales
|
||||
|
||||
| Lo usa | Va a |
|
||||
|---|---|
|
||||
| Solo este servicio | `<servicio>/domain/` o `<servicio>/aplication/` según corresponda |
|
||||
| Más de un servicio | `sim-shared/domain/` o `sim-shared/aplication/` |
|
||||
| Es un puerto compartido (cola, JWT, ...) | `sim-shared/ports/` o `sim-shared/domain/*.port.ts` |
|
||||
|
||||
**No promuevas a `sim-shared` "por si acaso".** Si solo lo usa un servicio, mantenlo local. Promover prematuramente acopla servicios.
|
||||
|
||||
## Servicios y excepciones documentadas
|
||||
|
||||
Lista de servicios del monorepo y si pueden usarse como referencia del estilo:
|
||||
|
||||
| Servicio | ¿Referencia válida? | Notas |
|
||||
|---|---|---|
|
||||
| `_template/` | ✅ Sí — punto de partida oficial | Plantilla para nuevos servicios |
|
||||
| `sim-shared/` | ✅ Sí | Tipos, ports y adapters compartidos |
|
||||
| `sim-entrada-eventos/` | ✅ Sí (con olores conocidos, ver ANTI-PATTERNS) | Gateway HTTP → bus |
|
||||
| `sim-consumidor-nos/` | ✅ Sí | Worker NOS, ejemplo de consumer típico |
|
||||
| `sim-consumidor-objenious/` | ✅ Sí | Worker Objenious |
|
||||
| `sim-objenious-cron/` | ❌ **NO usar como referencia** | Excepción documentada por el equipo: no cumple la arquitectura por características específicas de la API de Objenious que consume. Si auditas este servicio, los desvíos del estilo son intencionales y no deben reportarse como bloqueantes salvo que indiques explícitamente que estás auditando esta excepción. |
|
||||
|
||||
Si un servicio se aleja del estilo, debe quedar documentado aquí y en su README local con la razón.
|
||||
|
||||
## Health, puertos y boot
|
||||
|
||||
- Cada servicio expone `GET /health` que devuelve `{ status: "ok" }` con 200.
|
||||
- Puertos asignados en `README.md` (3000 gateway, 3001 NOS, 3002 Objenious...). Al añadir uno nuevo, actualiza el README.
|
||||
- Boot: conecta RMQ ANTES de empezar a aceptar HTTP, si el servicio depende del bus para servir. Si no depende del bus para HTTP (p.ej. solo health), puedes conectarlo en background.
|
||||
|
||||
## Tests
|
||||
|
||||
- `vitest`. Tests al lado del archivo: `X.ts` ↔ `X.test.ts`.
|
||||
- Tres niveles, cada uno con criterio distinto:
|
||||
- **Domain** (puro): sin mocks, sin async, sin I/O. Si necesitas mockear algo, está mal ubicado.
|
||||
- **Application** (usecase): mocks de ports. Verifica orquestación, no implementación de adapters.
|
||||
- **Infrastructure** (adapter): contra BDD/RMQ reales o testcontainers. Es lento — sólo tests críticos.
|
||||
- `supertest` para tests HTTP de extremo a extremo.
|
||||
|
||||
**Señal de buena arquitectura:** los tests de aplicación se escriben sin tocar `pg`, `amqplib` ni `express`. Si necesitas levantar Postgres para testear un usecase, tienes una fuga.
|
||||
37
.claude/commands/audit.md
Normal file
37
.claude/commands/audit.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Audita un package del monorepo contra el estilo de la casa usando la skill sf-backend-architecture en modo auditor.
|
||||
---
|
||||
|
||||
# /audit — auditoría arquitectónica de un package
|
||||
|
||||
Invoca la skill `sf-backend-architecture` en **modo auditor** para revisar el package indicado contra el estilo del repo.
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` debe ser la ruta o nombre del package a auditar (ej. `packages/sim-consumidor-nos`, `sim-entrada-eventos`, `sim-shared`).
|
||||
|
||||
Si no se proporciona argumento, pregunta al usuario qué package quiere auditar antes de continuar.
|
||||
|
||||
## Excepciones
|
||||
|
||||
- **`sim-objenious-cron` está exento.** Si `$ARGUMENTS` apunta a este package, avisa al usuario antes de continuar: el cron es una excepción arquitectónica intencional documentada en memoria y en `references/HOUSE-STYLE.md`. Solo audítalo si el usuario confirma explícitamente.
|
||||
- **`_template/` no se audita.** Es la plantilla de referencia; no tiene sentido auditarla contra sí misma.
|
||||
|
||||
## Cómo proceder
|
||||
|
||||
1. Invoca la skill `sf-backend-architecture` (con la herramienta `Skill`). Si por alguna razón no carga, indica al usuario el problema en lugar de continuar a ciegas.
|
||||
2. Confirma con la skill que estás en **modo 2 (auditor)**.
|
||||
3. Carga `references/AUDIT-CHECKLIST.md` y `references/HOUSE-STYLE.md` y `references/CODE-STYLE.md` antes de empezar la revisión.
|
||||
4. Recorre el package siguiendo la estructura del checklist, sección a sección.
|
||||
5. Emite el informe en el formato que define la skill (12 secciones + veredicto final).
|
||||
|
||||
## Formato del reporte
|
||||
|
||||
Sigue el formato exacto del modo auditor de la skill. Resumen final con:
|
||||
|
||||
- **Veredicto:** alineado / con olores / fuera del estilo.
|
||||
- **Bloqueantes:** desvíos críticos que rompen la arquitectura.
|
||||
- **Olores:** desvíos menores, oportunidades de mejora.
|
||||
- **Conformes:** lo que sí está bien (breve).
|
||||
|
||||
No abras la sesión proponiendo fixes — el modo auditor reporta, no implementa. Si el usuario quiere arreglar lo que sale, esa es una segunda fase aparte.
|
||||
62
.claude/commands/check.md
Normal file
62
.claude/commands/check.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
description: Comprueba typecheck, lint, format y tests del workspace. Opcional, no bloqueante. No hay CI que lo ejecute, así que el repo arrastra errores legacy.
|
||||
---
|
||||
|
||||
# /check — verificación local manual
|
||||
|
||||
Ejecuta las comprobaciones del workspace. Argumento opcional: ruta de package o fichero para acotar (ej. `packages/sim-consumidor-nos`).
|
||||
|
||||
## Cómo ejecutar
|
||||
|
||||
Si **no** hay argumento, corre las cuatro comprobaciones del workspace completo:
|
||||
|
||||
```bash
|
||||
yarn typecheck
|
||||
yarn lint
|
||||
yarn format:check
|
||||
yarn vitest run
|
||||
```
|
||||
|
||||
Si hay argumento (`$ARGUMENTS`), acota:
|
||||
|
||||
- `$ARGUMENTS` apunta a un package → ejecuta los comandos dentro de ese workspace cuando sea posible (`yarn workspace <name> typecheck` no existe globalmente; usa `yarn lint $ARGUMENTS` para acotar lint, y `yarn vitest run $ARGUMENTS/**/*.test.ts` para acotar tests).
|
||||
- `$ARGUMENTS` apunta a un fichero `.test.ts` → solo ejecuta `yarn vitest run $ARGUMENTS`.
|
||||
|
||||
Ejecuta los comandos en paralelo cuando sean independientes; reporta los resultados al final.
|
||||
|
||||
## Cómo interpretar los resultados
|
||||
|
||||
**Importante:** este repo NO tiene CI ejecutando estas comprobaciones. Es esperable que arrastre errores legacy de typecheck, lint y format. La regla:
|
||||
|
||||
- **NO bloquees** el trabajo del usuario por errores preexistentes.
|
||||
- **SÍ alerta** sobre errores que el cambio actual haya introducido o tocado.
|
||||
- Si no puedes distinguir nuevos vs preexistentes (no tienes diff de referencia), reporta el conteo total y deja al usuario decidir.
|
||||
|
||||
## Formato de reporte
|
||||
|
||||
Resumen breve, por comprobación:
|
||||
|
||||
```text
|
||||
typecheck: 47 errores (estado del repo, no necesariamente de tu cambio)
|
||||
lint: 132 errores, 89 warnings (idem)
|
||||
format: 3 ficheros con formato incorrecto
|
||||
tests: ✅ 24/24 passing
|
||||
```
|
||||
|
||||
Si has hecho cambios en esta sesión y puedes correlacionar errores con esos cambios, sepáralos:
|
||||
|
||||
```text
|
||||
typecheck:
|
||||
- 1 error nuevo en packages/sim-shared/domain/Order.ts:42 (introducido por este cambio)
|
||||
- 46 errores preexistentes (sin cambios)
|
||||
```
|
||||
|
||||
## Cuándo invocarlo
|
||||
|
||||
Es **opcional**. Casos de uso típicos:
|
||||
|
||||
- Antes de abrir un PR, para tener una idea del estado.
|
||||
- Tras un cambio grande, para detectar si has roto algo.
|
||||
- Para acotar a un package que estás tocando (`/check packages/sim-consumidor-nos`).
|
||||
|
||||
No lo invoques de oficio en cada turno — solo cuando aporte valor.
|
||||
73
.claude/commands/md-lint.md
Normal file
73
.claude/commands/md-lint.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
description: Revisa y corrige avisos comunes de markdownlint en ficheros .md del repo (raíz, .claude, .agents, packages...). No hay devDep ni CI; el repaso es manual y se invoca a demanda.
|
||||
---
|
||||
|
||||
# /md-lint — limpieza de markdown
|
||||
|
||||
Aplica un repaso de estilo a los `.md` del repo según las reglas que más se cuelan en este proyecto. No hay `markdownlint-cli2` instalado y no se quiere añadir como devDep — el barrido es manual contra el set de reglas de abajo.
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` opcional. Si está, acota el barrido a esa ruta (fichero concreto, carpeta, glob). Si no, barre todo el repo excluyendo:
|
||||
|
||||
- `node_modules/`
|
||||
- `dist/`
|
||||
- `.git/`
|
||||
- `coverage/`
|
||||
|
||||
Para listar candidatos:
|
||||
|
||||
```bash
|
||||
find . -name "*.md" -not -path "./node_modules/*" -not -path "./dist/*" -not -path "./.git/*" -not -path "*/coverage/*"
|
||||
```
|
||||
|
||||
## Reglas que se revisan
|
||||
|
||||
Set mínimo, alineado con lo que el editor del usuario marca y con el estilo ya presente en `README.md` y `CLAUDE.md`:
|
||||
|
||||
- **MD004 — list-style:** listas con `-`, no con `*`.
|
||||
- **MD030 — list-marker-space:** un único espacio tras `-` o `1.` (no `* ` ni `1. `).
|
||||
- **MD032 — blanks-around-lists:** una línea en blanco antes y después de cada lista.
|
||||
- **MD031 — blanks-around-fences:** una línea en blanco antes y después de cada bloque ` ``` `.
|
||||
- **MD036 — no-emphasis-as-heading:** `**Sección:**` en línea propia → heading real (`####`). Si la línea es la firma del documento o contenido decorativo (no es un encabezado lógico), convertir a texto plano en vez de inventar un heading.
|
||||
- **MD040 — fenced-code-language:** todo bloque ` ``` ` debe declarar lenguaje (`text`, `bash`, `ts`, `json`...).
|
||||
- **MD026 — no-trailing-punctuation-in-heading:** headings sin `:` ni `.` al final.
|
||||
- **MD047 — single-trailing-newline:** el fichero termina con un único `\n` final.
|
||||
- **MD034 — no-bare-urls:** URLs en texto deben ir en `<...>` o como link `[texto](url)`.
|
||||
|
||||
No se tocan:
|
||||
|
||||
- Contenido (typos, gramática, redacción) — solo formato. Excepción: si encuentras un typo obvio en un encabezado o una frase muy corta, repórtalo al final pero no lo arregles sin permiso.
|
||||
- Longitud de línea (MD013): el repo no la fuerza.
|
||||
- Estilo de heading ATX vs Setext: ya está unificado en ATX (`#`).
|
||||
|
||||
## Cómo proceder
|
||||
|
||||
1. Resuelve la lista de ficheros (`$ARGUMENTS` o barrido completo).
|
||||
2. Si son más de ~5 ficheros, despacha el trabajo a subagentes en paralelo agrupando por carpeta (ver skill `dispatching-parallel-agents`). Cada subagente recibe un grupo y este mismo set de reglas.
|
||||
3. Para cada fichero, lee y aplica las reglas con `Edit`. No reescribas el fichero entero con `Write` salvo que el delta sea masivo.
|
||||
4. No introduzcas cambios fuera del set de reglas. No reordenes secciones, no añadas contenido.
|
||||
|
||||
## Formato de reporte
|
||||
|
||||
Tabla resumen al terminar:
|
||||
|
||||
```text
|
||||
fichero cambios
|
||||
----------------------------- -------
|
||||
CONTRIBUTING.md 12 (MD004, MD030, MD036, typo)
|
||||
.claude/rules/code-style.md 0
|
||||
.agents/skills/.../HOUSE.md 3 (MD031, MD040)
|
||||
```
|
||||
|
||||
Si encontraste typos o problemas de contenido que no arreglaste (por estar fuera de scope), lístalos en una sección **Sugerencias** al final para que el usuario decida.
|
||||
|
||||
## Cuándo invocarlo
|
||||
|
||||
Es opcional, no hay CI que lo ejecute. Casos típicos:
|
||||
|
||||
- Tras editar varios `.md` seguidos y que el editor empiece a marcar avisos.
|
||||
- Antes de un PR que toca documentación.
|
||||
- Limpieza periódica si el repo arrastra inconsistencias.
|
||||
|
||||
No lo invoques de oficio.
|
||||
29
.claude/rules/code-style.md
Normal file
29
.claude/rules/code-style.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Code style — reglas mínimas
|
||||
|
||||
Subset siempre cargado. Para detalle completo (ejemplos, casos borde, justificaciones) ver [`.agents/skills/sf-backend-architecture/references/CODE-STYLE.md`](../../.agents/skills/sf-backend-architecture/references/CODE-STYLE.md) — invoca la skill `sf-backend-architecture` para cargarlo bajo demanda.
|
||||
|
||||
## Reglas que aplican siempre
|
||||
|
||||
- **Comentarios en español.** Inline, JSDoc, TODO. Sin excepciones.
|
||||
- **Identificadores:** español para dominio (`iccid`, `activarLinea`), inglés para infraestructura técnica (`httpClient`, `eventBus`).
|
||||
- **Funciones y métodos:** `camelCase`, verbo en infinitivo (`getUsuario`, `activarLinea`, `publishEvent`).
|
||||
- **Variables locales y parámetros:** `camelCase` (`activationDate`, `msgData`).
|
||||
- **Constantes literales de módulo:** `UPPER_SNAKE_CASE` (`DEFAULT_LIMIT = 1000`). Instancias configuradas (aunque sean `const`) van en `camelCase` (`const eventBus = new RabbitMQEventBus(...)`).
|
||||
- **Clases, interfaces y tipos:** `PascalCase`.
|
||||
- **Sin prefijo `I`** en interfaces (`EventBus`, no `IEventBus`).
|
||||
- **`type` por defecto, `interface` solo para ports** (contratos implementados por adapters).
|
||||
- **Sin `enum`** — usar uniones de strings literales (`type X = 'a' | 'b'`).
|
||||
- **Sin `any` en código nuevo** salvo en boundaries de I/O externo con comentario justificando. Preferencia: `unknown` + type guard.
|
||||
- **`async/await` siempre.** Nada de `.then().catch()` salvo fire-and-forget consciente con comentario.
|
||||
- **Tests en español:** `describe` e `it` ambos (`it('debería ...')`).
|
||||
- **Política de tests (TDD por defecto):** todo código nuevo se escribe con TDD y el repo mantiene ≥70% de cobertura. Tests del nivel apropiado (domain puro / application con mocks de ports / infrastructure cuando añade adapter). Bugs corregidos requieren test de regresión que reproduzca el fallo. Estrategia detallada en `HOUSE-STYLE.md` § Tests.
|
||||
- **Errores:** `Result<E, D>` para fallos esperables, `throw` solo para invariantes rotas.
|
||||
- **Aplicación:** estricto en código nuevo, oportunista al tocar código existente. No PRs masivos de retrofit.
|
||||
|
||||
## Excepciones de este repo
|
||||
|
||||
> Estas excepciones son específicas de **sf-sim**. Al copiar este fichero a un repo nuevo, sustituye esta sección (o bórrala) — los defaults de arriba aplican sin estas relajaciones.
|
||||
|
||||
- **Política de tests:** sf-sim es legacy con cobertura ~12%. La regla TDD + 70% es **aspiracional, no bloqueante**. Estricta para código nuevo; al tocar legacy retrofittear tests es opcional pero valorado; nada de retrofits masivos. La excepción decae cuando alcancemos el 70% global.
|
||||
- `sim-objenious-cron/` no sigue las convenciones arquitectónicas: sus desvíos son intencionales por la API que consume. No auditar contra este documento salvo petición explícita.
|
||||
- Carpeta `aplication/` (con typo) se mantiene tal cual hasta refactor coordinado de los path aliases del monorepo.
|
||||
39
.claude/rules/git-conventions.md
Normal file
39
.claude/rules/git-conventions.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Git conventions
|
||||
|
||||
Subset siempre cargado. Para guía completa (PRs, flujo de revisión, propósito) ver [`CONTRIBUTING.md`](../../CONTRIBUTING.md).
|
||||
|
||||
## Branches
|
||||
|
||||
- **Con ticket:** `WEBINT-XXX_descripcion-breve` — el ticket actúa como tipo.
|
||||
- **Sin ticket:** `tipo/descripcion-breve` con `tipo` ∈ `feat` | `fix` | `docs` | `style` | `refactor` | `test` | `chore`.
|
||||
|
||||
Ejemplos: `WEBINT-338_tiempo_suspension`, `feat/gateway-francia`, `fix/correlation-id`.
|
||||
|
||||
Nunca trabajar directo sobre `main`.
|
||||
|
||||
## Commits — Conventional Commits
|
||||
|
||||
Formato:
|
||||
|
||||
```text
|
||||
tipo(alcance): descripción breve en imperativo
|
||||
|
||||
[Cuerpo opcional]
|
||||
|
||||
[Pie opcional: refs a issues, breaking changes]
|
||||
```
|
||||
|
||||
`tipo` ∈ `feat` | `fix` | `docs` | `style` | `refactor` | `perf` | `test` | `chore`.
|
||||
|
||||
Ejemplos:
|
||||
|
||||
- `feat(auth): implementar login con Google`
|
||||
- `fix(db): corregir error de conexión en timeout`
|
||||
- `docs(readme): actualizar instrucciones de instalación`
|
||||
|
||||
Descripción en español, imperativo, sin punto final, minúscula tras los dos puntos.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Repo en Gitea self-hosted: `git.savefamilygps.net/SaveFamily/sf-sim` (no usar `gh`).
|
||||
- Reviewer designado: **Alvar San Martin** (`alvarsanmartin@savefamilygps.com`). Asignarlo en cada PR y esperar su aprobación antes de fusionar.
|
||||
1
.claude/skills/clean-ddd-hexagonal
Symbolic link
1
.claude/skills/clean-ddd-hexagonal
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/clean-ddd-hexagonal
|
||||
29
.env
29
.env
@@ -1,29 +0,0 @@
|
||||
PORT=3000
|
||||
API_HOSTNAME=0.0.0.0
|
||||
RABBITMQ_USER=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
|
||||
ENVIORMENT=development
|
||||
|
||||
RABBITMQ_HOST=rabbitmq-sim-broker
|
||||
# RABBITMQ_HOST=localhost
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_USER=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
RABBITMQ_SECURE=false
|
||||
RABBITMQ_VHOST=sim-vhost
|
||||
|
||||
# Hay cosas que unificar de varios servicios
|
||||
POSTGRES_HOST=postgresql-sim
|
||||
# POSTGRES_HOST=localhost
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_DATABASE=postgres
|
||||
POSTGRES_PORT=5433
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=1234
|
||||
|
||||
# Para el postgres local para generar el script de resultado de migraciones
|
||||
PGHOST=localhost
|
||||
PGUSER=alvar
|
||||
PGPASSWORD=alvar
|
||||
PGPORT=5433
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -20,3 +20,14 @@ node_modules
|
||||
|
||||
|
||||
dist/*
|
||||
|
||||
.env
|
||||
|
||||
# Knowledge graph generado localmente por la skill understand-anything
|
||||
.understand-anything/
|
||||
|
||||
# Settings de Claude Code locales por desarrollador
|
||||
.claude/settings.local.json
|
||||
|
||||
# Workspaces de skill-creator (artefactos de iteración, no la skill en sí)
|
||||
.agents/skills/*-workspace/
|
||||
|
||||
@@ -3,3 +3,9 @@ compressionLevel: mixed
|
||||
enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmScopes:
|
||||
sf-alvar:
|
||||
npmRegistryServer: "https://git.savefamilygps.net/api/packages/SaveFamily/npm/"
|
||||
|
||||
npmRegistryServer: "https://registry.npmjs.org/"
|
||||
|
||||
68
CLAUDE.md
Normal file
68
CLAUDE.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Comandos
|
||||
|
||||
Yarn 4 con workspaces. Desde la raíz:
|
||||
|
||||
- `yarn dev` — arranca todos los servicios en watch (tsx).
|
||||
- `yarn build` — `tsc --build` por workspace + crea `dist/packages/node_modules` con symlinks para que ESM resuelva `sim-shared` y `sim-consumidor-objenious`.
|
||||
- `yarn start` — arranca los servicios desde `dist/`.
|
||||
- `yarn typecheck` — `tsc --noEmit` global.
|
||||
- `yarn lint` / `yarn lint:fix` — ESLint.
|
||||
- `yarn format` / `yarn format:check` — Prettier.
|
||||
- `yarn test` — vitest en watch (todos los packages).
|
||||
- `yarn vitest run packages/<pkg>/path/file.test.ts` — un único test, single-shot.
|
||||
- `yarn migrate` — aplica migraciones de `deployment/database/migrations` con `@sf-alvar/db-migrate`.
|
||||
|
||||
Stack local con Docker (RabbitMQ + Postgres + servicios):
|
||||
|
||||
- `./build.local.sh` — build de imágenes.
|
||||
- `./run.local.sh` — `docker compose up --watch`.
|
||||
- `./stop.local.sh` — `docker compose down -v` (borra volúmenes).
|
||||
|
||||
ESM puro: los imports llevan `.js` aunque el archivo sea `.ts`. Las rutas usan path aliases por servicio (`#config/*`, `#adapters/*`, `#domain/*`, `#ports/*`).
|
||||
|
||||
## Arquitectura
|
||||
|
||||
Monorepo de microservicios que recibe peticiones HTTP, las publica en RabbitMQ y las consume por compañía proveedora de SIM. La compañía se resuelve en el gateway a partir del ICCID y se inyecta en el routing key.
|
||||
|
||||
Packages en `packages/`:
|
||||
|
||||
- **`sim-shared/`** — tipos, ports y adapters compartidos (`EventBus`/`RabbitMQEventBus`, `Result<E,D>`, `OrderRepository`, `PgClient`, JWT, `SimEvents`).
|
||||
- **`sim-entrada-eventos/`** — gateway HTTP (Express, :3000). Valida y publica al exchange.
|
||||
- **`sim-consumidor-nos/`** — worker NOS (:3001).
|
||||
- **`sim-consumidor-objenious/`** — worker Objenious (:3002).
|
||||
- **`sim-objenious-cron/`** — cron de seguimiento de mass-actions de Objenious. **No sigue la arquitectura DDD del resto**, excepción intencional documentada por las particularidades de la API que consume; no auditarlo contra el estilo de la casa salvo petición explícita.
|
||||
- **`_template/`** — plantilla oficial para servicios nuevos.
|
||||
|
||||
Cada servicio (excepto el cron) sigue capas DDD/Hexagonal:
|
||||
|
||||
- `domain/` — entidades, eventos, ports (`*.port.ts`).
|
||||
- `aplication/` — usecases (`X.usecases.ts`), controllers, validators. La carpeta se llama `aplication` (con "p" simple) **intencionalmente**: cambiarla rompe los path aliases de todo el monorepo.
|
||||
- `infrastructure/` — adapters (repositorios, clientes HTTP, routers Express `*Routes.http.ts`).
|
||||
- `config/` — composition root + env.
|
||||
|
||||
**RabbitMQ**: exchange principal `sim.exchange` (topic). Routing keys `sim.[compañia].[acción]`. Retries con header `x-retry-count`; tras `maxRetry` → DLX en `sim.dlx`. Cada mensaje lleva `correlation_id` (uuidv7) que liga evento ↔ order en Postgres. La topología se declara solo en código de cada consumer; en JSON solo el broker base. Diagrama en `imgs/diagrama-rabbit.png`.
|
||||
|
||||
**Errores**: `Result<E, D>` (de `sim-shared/domain/Result.ts`) para fallos esperables (negocio, I/O). `throw` solo para invariantes rotas.
|
||||
|
||||
**Inyección de dependencias**: manual, por constructor con objeto `args: { dep1, dep2, ... }` (NO posicional). Cableado en `index.ts` o `config/*.config.ts` — nunca dentro de un usecase, controller o adapter. Los tipos del constructor apuntan al **port**, nunca al adapter concreto.
|
||||
|
||||
## Skill experta y referencias
|
||||
|
||||
Para cualquier trabajo no trivial de diseño, revisión o auditoría arquitectónica, invoca la skill **`sf-backend-architecture`** (modos: asesor de diseño / auditor de servicios — preguntar al usuario si ambiguo). Su auto-trigger tiene recall bajo, invócala explícitamente. Referencias en `.agents/skills/sf-backend-architecture/references/`:
|
||||
|
||||
- `HOUSE-STYLE.md` — carpetas, naming de ficheros, DI, Result, ESM.
|
||||
- `CODE-STYLE.md` — naming a nivel código, idioma, `interface`/`type`, `any`, async, tests.
|
||||
- `ANTI-PATTERNS.md` — olores conocidos del repo.
|
||||
- `AUDIT-CHECKLIST.md` — checklist exhaustivo del modo auditor.
|
||||
- `EVENTS-RABBITMQ.md` — publish/consume, routing keys, retries, DLX, outbox, idempotencia.
|
||||
|
||||
Lee solo la referencia que necesites; no las cargues todas por defecto.
|
||||
|
||||
Convenciones de contribución en [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
|
||||
@.claude/rules/code-style.md
|
||||
@.claude/rules/git-conventions.md
|
||||
104
CONTRIBUTING.md
Normal file
104
CONTRIBUTING.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Guía de Contribución
|
||||
|
||||
¡Gracias por tu interés en contribuir a este proyecto! Este documento establece las pautas para asegurar que nuestro código se mantenga de alta calidad y que nuestra historia de git sea limpia y legible.
|
||||
|
||||
## 1. Flujo de Trabajo con Git
|
||||
|
||||
### Ramas (Branches)
|
||||
|
||||
Utilizamos un modelo de ramas basado en el propósito del cambio.
|
||||
|
||||
- **Rama Principal**: `main`. Esta rama siempre debe ser estable y desplegable.
|
||||
- **Creación de Ramas**: Crea una nueva rama para cada tarea, feature o bugfix. Nunca trabajes directamente sobre `main`.
|
||||
|
||||
#### Convención de nombres de ramas
|
||||
|
||||
- **Con ticket:** `WEBINT-XXX_descripcion-breve` — el ticket actúa como tipo.
|
||||
- **Sin ticket:** `tipo/descripcion-breve`
|
||||
|
||||
Donde `tipo` puede ser:
|
||||
|
||||
- `feat`: Nuevas características o funcionalidades.
|
||||
- `fix`: Corrección de errores.
|
||||
- `docs`: Cambios solo en documentación.
|
||||
- `style`: Cambios que no afectan el significado del código (espacios, formato, etc).
|
||||
- `refactor`: Cambio de código que no arregla un bug ni añade una característica.
|
||||
- `test`: Añadir tests faltantes o corregir existentes.
|
||||
- `chore`: Cambios en el proceso de construcción o herramientas auxiliares.
|
||||
|
||||
Ejemplos:
|
||||
|
||||
- `WEBINT-338_tiempo_suspension`
|
||||
- `feat/gateway-francia`
|
||||
- `fix/correlation-id`
|
||||
|
||||
### Commits
|
||||
|
||||
Seguimos la convención de **Conventional Commits** para nuestros mensajes de commit. Esto ayuda a generar changelogs automáticos y facilita la lectura del historial.
|
||||
|
||||
#### Formato
|
||||
|
||||
```text
|
||||
tipo(alcance): descripción breve en imperativo
|
||||
|
||||
[Cuerpo opcional: descripción más detallada del cambio]
|
||||
|
||||
[Pie opcional: referencias a issues, breaking changes]
|
||||
```
|
||||
|
||||
#### Tipos comunes
|
||||
|
||||
- `feat`: Una nueva característica.
|
||||
- `fix`: Una corrección de un bug.
|
||||
- `docs`: Cambios en la documentación.
|
||||
- `style`: Formato, puntos y comas faltantes, etc. (no cambios en la lógica).
|
||||
- `refactor`: Refactorización de código en producción.
|
||||
- `perf`: Cambio de código que mejora el rendimiento.
|
||||
- `test`: Añadir o corregir tests.
|
||||
- `chore`: Tareas rutinarias, actualizaciones de dependencias, etc.
|
||||
|
||||
Ejemplos:
|
||||
|
||||
- `feat(auth): implementar login con Google`
|
||||
- `fix(db): corregir error de conexión en timeout`
|
||||
- `docs(readme): actualizar instrucciones de instalación`
|
||||
|
||||
### Pull Requests (PRs)
|
||||
|
||||
1. Asegúrate de que tu rama está actualizada con `main`.
|
||||
2. Abre un Pull Request dando una descripción clara de los cambios.
|
||||
3. Enlaza cualquier Issue relacionado (ej. `Closes #123`).
|
||||
4. Asigna como revisor a **Alvar San Martin** (`alvarsanmartin@savefamilygps.com`) y espera su aprobación antes de fusionar.
|
||||
|
||||
## 2. Estilo de Código
|
||||
|
||||
Las convenciones de código (naming, idioma, TypeScript, tests) están en:
|
||||
|
||||
- [`.claude/rules/code-style.md`](.claude/rules/code-style.md) — reglas resumidas que aplican a cualquier cambio.
|
||||
- [`.agents/skills/sf-backend-architecture/references/CODE-STYLE.md`](.agents/skills/sf-backend-architecture/references/CODE-STYLE.md) — detalle completo con ejemplos y justificaciones.
|
||||
- [`.agents/skills/sf-backend-architecture/references/HOUSE-STYLE.md`](.agents/skills/sf-backend-architecture/references/HOUSE-STYLE.md) — convenciones de arquitectura (capas DDD, ports, Result, ESM).
|
||||
|
||||
## 3. Tests
|
||||
|
||||
Estrategia detallada en `HOUSE-STYLE.md` § Tests (vitest, tres niveles: domain puro / application con mocks de ports / infrastructure contra BD o testcontainers).
|
||||
|
||||
### Política por defecto (TDD)
|
||||
|
||||
- **Código nuevo:** se escribe con TDD (test primero) e incluye tests del nivel apropiado (domain puro si aplica; application con mocks; infrastructure si se añade un adapter).
|
||||
- **Cobertura mínima del repo:** 70%. Cada PR debe mantener o aumentar la cobertura.
|
||||
- **Bugs corregidos:** requieren al menos un test de regresión que reproduzca el fallo.
|
||||
- **Antes de abrir un PR:** los tests existentes deben pasar (`yarn vitest run` o `/check`).
|
||||
|
||||
### Excepción para este repo (sf-sim)
|
||||
|
||||
sf-sim es legacy con cobertura actual ~12%. La regla TDD + 70% es **aspiracional, no bloqueante**:
|
||||
|
||||
- Estricta para código nuevo (sí debe llevar tests).
|
||||
- Al tocar código legacy sin tests, retrofittearlos es opcional pero se valora positivamente. Nada de retrofits masivos.
|
||||
- La excepción decae cuando el repo alcance el 70% global.
|
||||
|
||||
> Al reutilizar estos ficheros de configuración en un repo nuevo, esta excepción no aplica — la política por defecto es la que rige.
|
||||
|
||||
---
|
||||
|
||||
Equipo de Desarrollo.
|
||||
22
README.md
22
README.md
@@ -12,13 +12,13 @@ La compañia a la que pertenece cada peticion y por tanto el servicio que lo va
|
||||
|
||||
## Decisiones pendientes
|
||||
|
||||
- [x] La capa worker según acción y la de operaciones de proveedores, se podrían unir en una sola con un enrutamiento por acción y compañía, pasando de tener claves `sim.[acción]` a `sim.[compañia].[acción]`. *Se ha aplicado el cambio ahora las routing keys tienen la estructura `sim.[compañia].[acción]`*
|
||||
- [x] La estructura de RMQ se genera por medio del JSON, igual habría que definir cada cola en el worker que la consuma para poder añadir workers sin parar el RMQ. *Se ha aplicado el cambio, ahora solo se define en el json el broker principal para garantizar que exita sin servicios consumidores. Sin embargo tal como estan estructurdos los proyectos no es posible reiniciar solo un servicio*
|
||||
- [x] La capa worker según acción y la de operaciones de proveedores, se podrían unir en una sola con un enrutamiento por acción y compañía, pasando de tener claves `sim.[acción]` a `sim.[compañia].[acción]`. _Se ha aplicado el cambio ahora las routing keys tienen la estructura `sim.[compañia].[acción]`_
|
||||
- [x] La estructura de RMQ se genera por medio del JSON, igual habría que definir cada cola en el worker que la consuma para poder añadir workers sin parar el RMQ. _Se ha aplicado el cambio, ahora solo se define en el json el broker principal para garantizar que exita sin servicios consumidores. Sin embargo tal como estan estructurdos los proyectos no es posible reiniciar solo un servicio_
|
||||
- [ ] Versionado de la API.
|
||||
- [x] Método para sacar la compañía a partir del iccid, o buscar en la BDD si no es posible. *De momento es un objeto Map en el servicio de gateway*
|
||||
- [ ] Cola de mensajes que no se han podido procesar. Distinguir según error de red; se reintenta; o error del propio mensaje; se envía a la cola de errores. v2 Se ha creado una cola de delay pero no se distingue el tipo de error, despues de n reintentos el mensaje va a la cola de dead-letter.
|
||||
- [ ] Seguimiento de las peticiones de Objenious, por cada peticion hay qye hacer un seguimiento del request y de los mass action para saber si las activaciones han tenido exito. Habria que crear otra cola para consultar cada x tiempo o mejor un cron?
|
||||
- [ ] Actualizar en la base de datos el estado de las peticiones de las sim y añadir el número de telefono cuando se activen o cuando se cumpla una accion.
|
||||
- [x] Método para sacar la compañía a partir del iccid, o buscar en la BDD si no es posible. _De momento es un objeto Map en el servicio de gateway_
|
||||
- [ ] Cola de mensajes que no se han podido procesar. Distinguir según error de red; se reintenta; o error del propio mensaje; se envía a la cola de errores. v2 Se ha creado una cola de delay pero no se distingue el tipo de error, después de n reintentos el mensaje va a la cola de dead-letter.
|
||||
- [x] Seguimiento de las peticiones de Objenious, por cada peticion hay qye hacer un seguimiento del request y de los mass action para saber si las activaciones han tenido exito. Habria que crear otra cola para consultar cada x tiempo o mejor un cron?
|
||||
- [x] Actualizar en la base de datos el estado de las peticiones de las sim y añadir el número de telefono cuando se activen o cuando se cumpla una accion.
|
||||
|
||||
## Versión con consumidores basados en la compañia
|
||||
|
||||
@@ -32,8 +32,14 @@ OBJENIOUS (33)2011a
|
||||
|
||||
## Diagrama de las colas de Rabbitmq
|
||||
|
||||
Actualmente la topologia de las colas consiste en un exchage principal que recibe todos los mensajes y los redistribuye en las colas de cada empresa y a la de logs. Para evitar reintentos de mensajes instantaneos, que podrian ser inutiles si algún servicio se ha caido, se ha añadido una cola de delay que alamcena los mesajes fallidos durante n segundos antes de ser reenviados al exchange principal. Si despues de n reintentos el mensaje sigue fallando se envia a la cola de dead-letter para ser procesado manualmente.
|
||||
Actualmente la topología de las colas consiste en un exchage principal que recibe todos los mensajes y los redistribuye en las colas de cada empresa y a la de logs. Para evitar reintentos de mensajes instantáneos, que podrían ser inútiles si algún servicio se ha caído, se ha añadido una cola de delay que almacena los mensajes fallidos durante n segundos antes de ser reenviados al exchange principal. Si después de n reintentos el mensaje sigue fallando se envía a la cola de dead-letter para ser procesado manualmente.
|
||||
|
||||

|
||||
|
||||
La decisión del numero de reintentos y la cola de dlx se hace en los servicios, con una configuracion global en shared.
|
||||
La decisión del numero de reintentos y la cola de dlx se hace en los servicios, con una configuración global en shared.
|
||||
|
||||
## Puertos internos para comunicaciones entre sub-servicios
|
||||
|
||||
- **3000**: Gateway (sim-entrada-eventos)
|
||||
- **3001**: Consumidor NOS (sim-consumidor-nos)
|
||||
- **3002**: Consumidor Objenious (sim-consumidor-objenious)
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
#/bin/bash
|
||||
rm deployment/database/init.sql
|
||||
# cat deployment/database/*.sql >deployment/database/init.sql
|
||||
cp deployment/database/esquema_final* deployment/database/init.sql
|
||||
|
||||
# compatibilidad con postgresql < 17
|
||||
sed -i '/\\restrict/d' deployment/database/init.sql
|
||||
sed -i '/\\unrestrict/d' deployment/database/init.sql
|
||||
|
||||
docker compose -f deployment/local/docker/docker-compose.yaml --project-directory ./ build
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# stage base para coordinar las fases de build y ejecucion
|
||||
FROM node:22-alpine AS base
|
||||
WORKDIR /usr/local/app
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./package.json ./
|
||||
#COPY ./package.json ./yarn.lock ./
|
||||
RUN corepack enable && \
|
||||
corepack prepare yarn@4.12.0 --activate
|
||||
# copia el codigo en general
|
||||
|
||||
20
deployment/database/base/xx-volcado-objenious.sql
Normal file
20
deployment/database/base/xx-volcado-objenious.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
CREATE table if not exists objenious_lines (
|
||||
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
simId BIGINT UNIQUE,
|
||||
status TEXT,
|
||||
iccid TEXT NOT NULL,
|
||||
msisdn TEXT,
|
||||
imei TEXT,
|
||||
imeiChangeDate TIMESTAMPTZ,
|
||||
offerCode TEXT,
|
||||
preactivationDate TIMESTAMPTZ, -- No viene con hora
|
||||
activationDate TIMESTAMPTZ,
|
||||
commercialStatus TEXT,
|
||||
commercialStatusDate TIMESTAMPTZ,
|
||||
billingStatus TEXT,
|
||||
billingStatusChangeDate TIMESTAMPTZ,
|
||||
billingActivationDate TIMESTAMPTZ,
|
||||
createDate TIMESTAMPTZ,
|
||||
raw JSONB,
|
||||
hash TEXT
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Para la tarea WEBINT-328-Pausas-cacelaciones.
|
||||
* Almacena las pausas/cancelaciones que no se han podido hacer porque la linea esta en
|
||||
* "Test"
|
||||
*/
|
||||
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE SUSPENDTERMINATE AS ENUM ('suspend','terminate');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pause_cancel_tasks (
|
||||
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
iccid TEXT NOT NULL,
|
||||
operation_type SUSPENDTERMINATE,
|
||||
last_checked TIMESTAMPTZ, -- Última vez que se ha comprobado que no esté en test
|
||||
activation_date TIMESTAMPTZ, -- Fecha de activacion para comprobar si ha pasdo un mes
|
||||
next_check TIMESTAMPTZ, -- Si se ha comprobado se asignará la siguiente fecha de revision
|
||||
|
||||
completed_date TIMESTAMPTZ, -- Cuando se ha completado, para bien o mal.
|
||||
error TEXT,
|
||||
action_data JSONB -- datos de la operacion original.
|
||||
);
|
||||
|
||||
-- Indice de las tareas que no han terminado
|
||||
CREATE INDEX idx_pause_cancel_tasks_pending
|
||||
ON pause_cancel_tasks (next_check)
|
||||
WHERE completed_date IS NULL;
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ WORKDIR /home/node/app
|
||||
RUN corepack enable
|
||||
|
||||
COPY ./dist/packages ./packages
|
||||
COPY ./.yarnrc.yml ./
|
||||
COPY ./docs ./docs
|
||||
# Para las migraciones
|
||||
COPY ./deployment ./deployment
|
||||
|
||||
COPY ./package.json ./
|
||||
|
||||
# Force node-modules linker (no .yarnrc.yml in build context)
|
||||
RUN echo 'nodeLinker: node-modules' > .yarnrc.yml
|
||||
|
||||
RUN yarn install
|
||||
RUN yarn install
|
||||
|
||||
RUN mkdir -p dist && ln -sf ../packages dist/packages
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ services:
|
||||
- ${PORT}
|
||||
volumes:
|
||||
- ./.env:/home/node/app/.env:ro
|
||||
- ./sim-consumidor-nos.env:/home/node/app/packages/sim-consumidor-nos/.env:ro
|
||||
- ./sim-consumidor-objenious.env:/home/node/app/packages/sim-consumidor-objenious/.env:ro
|
||||
- ./sim-objenious-cron.env:/home/node/app/packages/sim-objenious-cron/.env:ro
|
||||
- ./obj.pem:/home/node/app/packages/sim-consumidor-objenious/obj.pem:ro
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/bin/sh
|
||||
cd /home
|
||||
|
||||
cd /home/node/app && yarn start
|
||||
cd /home/node/app
|
||||
yarn migrate
|
||||
yarn start
|
||||
|
||||
@@ -22,7 +22,7 @@ pipeline {
|
||||
}
|
||||
stage("🧱 Building") {
|
||||
steps {
|
||||
sh 'rm -rf dist/'
|
||||
sh 'rm -rf dist/'
|
||||
sh 'yarn run build'
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,10 @@ pipeline {
|
||||
cleanRemote: false,
|
||||
execCommand: "ln -sf $BASE_REMOTE_PATH/vault/savefamily/sf-sims/sim-consumidor-objenious.env $APP_REMOTE_PATH/sim-consumidor-objenious.env"
|
||||
),
|
||||
sshTransfer(
|
||||
cleanRemote: false,
|
||||
execCommand: "ln -sf $BASE_REMOTE_PATH/vault/savefamily/sf-sims/sim-consumidor-nos.env $APP_REMOTE_PATH/sim-consumidor-nos.env"
|
||||
),
|
||||
sshTransfer(
|
||||
cleanRemote: false,
|
||||
execCommand: "ln -sf $BASE_REMOTE_PATH/vault/savefamily/sf-sims/sim-objenious-cron.env $APP_REMOTE_PATH/sim-objenious-cron.env"
|
||||
@@ -60,11 +64,15 @@ pipeline {
|
||||
sourceFiles: "dist/**/*",
|
||||
excludes: "dist/**/node_modules/**"
|
||||
),
|
||||
sshTransfer(
|
||||
cleanRemote: false,
|
||||
remoteDirectory: "$APP_REMOTE_PATH",
|
||||
sourceFiles: "docs/**/*",
|
||||
),
|
||||
sshTransfer(
|
||||
cleanRemote: false,
|
||||
remoteDirectory: "$APP_REMOTE_PATH",
|
||||
sourceFiles: "deployment/database/**/*",
|
||||
removePrefix: "deployment",
|
||||
),
|
||||
sshTransfer(
|
||||
cleanRemote: false,
|
||||
@@ -88,6 +96,11 @@ pipeline {
|
||||
remoteDirectory: "$APP_REMOTE_PATH",
|
||||
sourceFiles: "package.json",
|
||||
),
|
||||
sshTransfer(
|
||||
cleanRemote: false,
|
||||
remoteDirectory: "$APP_REMOTE_PATH",
|
||||
sourceFiles: ".yarnrc.yml",
|
||||
),
|
||||
sshTransfer(
|
||||
cleanRemote: false,
|
||||
execCommand: "sh $APP_REMOTE_PATH/rebuild.sh"
|
||||
|
||||
@@ -14,6 +14,7 @@ COPY ./packages ./packages
|
||||
COPY tsconfig*.json ./
|
||||
COPY .env* ./
|
||||
COPY ./.yarnrc.yml ./
|
||||
COPY ./docs ./docs
|
||||
COPY ./deployment/local/docker/start.sh ./
|
||||
# Copiar el archivo de migrations? porque ahora no creo que se esté lanzando nada
|
||||
COPY ./deployment/database/migrations ./deployment/database/migrations
|
||||
|
||||
@@ -7,6 +7,7 @@ networks:
|
||||
services:
|
||||
rabbitmq-sim-broker:
|
||||
container_name: rabbitmq-sim-broker
|
||||
hostname: rabbitmq-sim
|
||||
image: "rabbitmq:4.2.2-management"
|
||||
ports:
|
||||
- "5672:5672"
|
||||
@@ -23,6 +24,7 @@ services:
|
||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
|
||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
|
||||
volumes:
|
||||
- ./rabbitmq-data/:/var/lib/rabbitmq/
|
||||
- ./rabbitmq_plugins/enabled_plugins:/etc/rabbitmq/enabled_plugins:ro
|
||||
- ./deployment/local/rabbit/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro
|
||||
- ./deployment/local/rabbit/definitions.json:/etc/rabbitmq/definitions.json:ro
|
||||
@@ -40,6 +42,9 @@ services:
|
||||
- path: ./packages
|
||||
action: sync
|
||||
target: /usr/local/app/packages
|
||||
- path: ./docs
|
||||
action: sync
|
||||
target: /usr/local/app/docs
|
||||
- path: ./package.json
|
||||
action: rebuild
|
||||
ports:
|
||||
@@ -72,7 +77,6 @@ services:
|
||||
- "${POSTGRES_PORT}:${POSTGRES_PORT}"
|
||||
volumes:
|
||||
- ./sql-data/:/var/lib/postgres/data
|
||||
- ./deployment/database/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
|
||||
25
docs/sim-api-documentation.html
Normal file
25
docs/sim-api-documentation.html
Normal file
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@ post {
|
||||
}
|
||||
|
||||
body:form-urlencoded {
|
||||
iccid: 8933201125065160331
|
||||
iccid: 8935103196306448300
|
||||
offer: SAVEFAMILY1
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,45 @@ post {
|
||||
}
|
||||
|
||||
body:form-urlencoded {
|
||||
iccid: 8933201125068886692
|
||||
iccid: 8933201125068890892
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
|
||||
docs {
|
||||
El endpoint recibe como body
|
||||
```
|
||||
{
|
||||
iccid: string,
|
||||
update_webhook?: string
|
||||
}
|
||||
```
|
||||
|
||||
`update_webhook` está en desarrollo, pero será donde se mande la actualizacion de la cancelación cuando haya una respuesta de la API externa.
|
||||
|
||||
Si la llamada tiene exito devuelve:
|
||||
``` json
|
||||
{
|
||||
data: {
|
||||
iccid: string,
|
||||
message_id: string,
|
||||
operation: "cancelation"
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
message_id se usará para la llamada /orders/message_id/}{message_id}
|
||||
|
||||
Si la llamada falla devolvera:
|
||||
```json
|
||||
{
|
||||
errors: {
|
||||
msg: string
|
||||
... (campos extra de gestion del error)
|
||||
}
|
||||
}
|
||||
```
|
||||
}
|
||||
|
||||
16
docs/sim-api/Docs.bru
Normal file
16
docs/sim-api/Docs.bru
Normal file
@@ -0,0 +1,16 @@
|
||||
meta {
|
||||
name: Docs
|
||||
type: http
|
||||
seq: 12
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseurl}}/docs/sim-api-documentation.html
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
26
docs/sim-api/France Suspended Lines.bru
Normal file
26
docs/sim-api/France Suspended Lines.bru
Normal file
@@ -0,0 +1,26 @@
|
||||
meta {
|
||||
name: France Suspended Lines
|
||||
type: http
|
||||
seq: 17
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseurl}}/france/lines?status=SUSPENDED&limit=100
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
params:query {
|
||||
status: SUSPENDED
|
||||
limit: 100
|
||||
}
|
||||
|
||||
vars:pre-request {
|
||||
iccid: 8933201125065160331
|
||||
~baseurl: http://localhost:3002
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
21
docs/sim-api/France Suspended Time.bru
Normal file
21
docs/sim-api/France Suspended Time.bru
Normal file
@@ -0,0 +1,21 @@
|
||||
meta {
|
||||
name: France Suspended Time
|
||||
type: http
|
||||
seq: 15
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseurl}}/france/lines/{{iccid}}/suspended-time
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
vars:pre-request {
|
||||
iccid: 8933201125065160331
|
||||
~baseurl: http://localhost:3002
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
@@ -15,7 +15,7 @@ params:query {
|
||||
}
|
||||
|
||||
body:form-urlencoded {
|
||||
iccid: 8933201125065160414
|
||||
iccid: 8933201125065160331
|
||||
}
|
||||
|
||||
settings {
|
||||
|
||||
21
docs/sim-api/ReActivate.bru
Normal file
21
docs/sim-api/ReActivate.bru
Normal file
@@ -0,0 +1,21 @@
|
||||
meta {
|
||||
name: ReActivate
|
||||
type: http
|
||||
seq: 13
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseurl}}/sim/reActivate
|
||||
body: formUrlEncoded
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:form-urlencoded {
|
||||
iccid: 8935103196306448300
|
||||
~offer: SAVEFAMILY1
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
34
docs/sim-api/collection.bru
Normal file
34
docs/sim-api/collection.bru
Normal file
@@ -0,0 +1,34 @@
|
||||
docs {
|
||||
Los endpoint tienen unos campos comunes de entrada:
|
||||
```ts
|
||||
{
|
||||
iccid: string,
|
||||
update_webhook?: string
|
||||
}
|
||||
```
|
||||
|
||||
`update_webhook` está en desarrollo, pero será donde se mande la actualizacion de la cancelación cuando haya una respuesta de la API externa.
|
||||
|
||||
Si la llamada tiene exito devuelve:
|
||||
```ts
|
||||
{
|
||||
data: {
|
||||
iccid: string,
|
||||
message_id: string,
|
||||
operation: string,
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
message_id se usará para la llamada /orders/message_id/}{message_id}
|
||||
|
||||
Si la llamada falla devolvera:
|
||||
```ts
|
||||
{
|
||||
errors: {
|
||||
msg: string
|
||||
... (campos extra de gestion del error)
|
||||
}
|
||||
}
|
||||
```
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
vars {
|
||||
baseurl: http://localhost:3000
|
||||
}
|
||||
color: #2E8A54
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
vars {
|
||||
baseurl: https://sf-sims.savefamilygps.net
|
||||
}
|
||||
color: #CE4F3B
|
||||
|
||||
4
docs/sim-api/environments/simconnections.bru
Normal file
4
docs/sim-api/environments/simconnections.bru
Normal file
@@ -0,0 +1,4 @@
|
||||
vars {
|
||||
baseurl: http://sim-connections.savefamilygps.net
|
||||
}
|
||||
color: #C77A0F
|
||||
20
docs/sim-api/test proxy.bru
Normal file
20
docs/sim-api/test proxy.bru
Normal file
@@ -0,0 +1,20 @@
|
||||
meta {
|
||||
name: test proxy
|
||||
type: http
|
||||
seq: 14
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseurl}}/simconnections/alai/select?iccid=1111111111111111111
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
params:query {
|
||||
iccid: 1111111111111111111
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
23
docs/sim-nos/Select Page.yml
Normal file
23
docs/sim-nos/Select Page.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
info:
|
||||
name: Select Page
|
||||
type: http
|
||||
seq: 6
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{baseurl}}/selectPage"
|
||||
params:
|
||||
- name: iccid
|
||||
value: "8935103196306448300"
|
||||
type: query
|
||||
disabled: true
|
||||
body:
|
||||
type: json
|
||||
data: ""
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
25
docs/sim-nos/Select.yml
Normal file
25
docs/sim-nos/Select.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
info:
|
||||
name: Select
|
||||
type: http
|
||||
seq: 5
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{baseurl}}/select?iccid=8935103196306448300"
|
||||
params:
|
||||
- name: iccid
|
||||
value: "8935103196306448300"
|
||||
type: query
|
||||
body:
|
||||
type: json
|
||||
data: |-
|
||||
{
|
||||
"iccid": "8933201125068890066"
|
||||
}
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
7
docs/sim-nos/environments/local.yml
Normal file
7
docs/sim-nos/environments/local.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
name: local
|
||||
color: "#2E8A54"
|
||||
variables:
|
||||
- name: baseurl
|
||||
value: http://localhost:3001
|
||||
- secret: true
|
||||
name: token
|
||||
7
docs/sim-nos/environments/prod.yml
Normal file
7
docs/sim-nos/environments/prod.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
name: prod
|
||||
color: "#CE4F3B"
|
||||
variables:
|
||||
- name: baseurl
|
||||
value: https://nosconnectcenter-api.iot-x.com
|
||||
- secret: true
|
||||
name: token
|
||||
10
docs/sim-nos/opencollection.yml
Normal file
10
docs/sim-nos/opencollection.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
opencollection: 1.0.0
|
||||
|
||||
info:
|
||||
name: sim-nos
|
||||
bundled: false
|
||||
extensions:
|
||||
bruno:
|
||||
ignore:
|
||||
- node_modules
|
||||
- .git
|
||||
22
docs/sim-nos/subscriber actions.yml
Normal file
22
docs/sim-nos/subscriber actions.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
info:
|
||||
name: subscriber actions
|
||||
type: http
|
||||
seq: 1
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{baseurl}}/subscribers/{{iccid}}/actions"
|
||||
auth:
|
||||
type: bearer
|
||||
token: "{{token}}"
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: iccid
|
||||
value: "8935103196306448300"
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
22
docs/sim-nos/subscriber info.yml
Normal file
22
docs/sim-nos/subscriber info.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
info:
|
||||
name: subscriber info
|
||||
type: http
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{baseurl}}/subscribers/{{iccid}}"
|
||||
auth:
|
||||
type: bearer
|
||||
token: "{{token}}"
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: iccid
|
||||
value: "8935103196306448300"
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
22
docs/sim-nos/subscriber products available.yml
Normal file
22
docs/sim-nos/subscriber products available.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
info:
|
||||
name: subscriber products available
|
||||
type: http
|
||||
seq: 4
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{baseurl}}/subscribers/{{iccid}}/products/available"
|
||||
auth:
|
||||
type: bearer
|
||||
token: "{{token}}"
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: iccid
|
||||
value: "8935103196306448300"
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
22
docs/sim-nos/subscribers.yml
Normal file
22
docs/sim-nos/subscribers.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
info:
|
||||
name: subscribers
|
||||
type: http
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{baseurl}}/subscribers"
|
||||
auth:
|
||||
type: bearer
|
||||
token: "{{token}}"
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: iccid
|
||||
value: "8935103196306448300"
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
@@ -5,16 +5,16 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://api-getway.objenious.com/ws/lines?pageSize=10&identifier.identifierType=ICCID&identifier.identifiers=8933201125065160455
|
||||
url: https://api-getway.objenious.com/ws/lines?pageSize=1000&simStatus=ACTIVATED
|
||||
body: formUrlEncoded
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:query {
|
||||
pageSize: 10
|
||||
identifier.identifierType: ICCID
|
||||
identifier.identifiers: 8933201125065160455
|
||||
~simStatus: ACTIVATED
|
||||
pageSize: 1000
|
||||
simStatus: ACTIVATED
|
||||
~identifier.identifierType: ICCID
|
||||
~identifier.identifiers: 8933201125065160455
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
|
||||
@@ -37,7 +37,7 @@ body:form-urlencoded {
|
||||
}
|
||||
|
||||
vars:pre-request {
|
||||
params.id: 14557
|
||||
params.id: 15102
|
||||
}
|
||||
|
||||
settings {
|
||||
|
||||
@@ -5,13 +5,13 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{actionsUrl}}/massActions?massActionId=5192767
|
||||
url: {{actionsUrl}}/massActions?massActionId=5363116
|
||||
body: formUrlEncoded
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:query {
|
||||
massActionId: 5192767
|
||||
massActionId: 5363116
|
||||
~identifier.identifierType: ICCID
|
||||
~identifier.identifiers: 8933201125065160463,8933201125065160422
|
||||
}
|
||||
|
||||
1163
docs/superpowers/plans/2026-05-06-base-backend-scaffold.md
Normal file
1163
docs/superpowers/plans/2026-05-06-base-backend-scaffold.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
# Diseño — Scaffold `base-backend`
|
||||
|
||||
- **Fecha**: 2026-05-06
|
||||
- **Rama de origen**: `chore/claude-code-setup` (sf-sim)
|
||||
- **Destino**: `~/code/ref/base-backend` (repo independiente, `git init`)
|
||||
- **Autor**: Jorge
|
||||
|
||||
## Contexto
|
||||
|
||||
Sf-sim es un monorepo TypeScript ESM con varios microservicios DDD/Hexagonal sobre RabbitMQ + Postgres. Una vez estabilizada la configuración de Claude Code en la rama `chore/claude-code-setup` (commits `9a5308c`, `b67d53c`, `5e77619`), se quiere extraer un **scaffold genérico** que sirva de punto de partida para futuros backends del mismo perfil arquitectónico.
|
||||
|
||||
El scaffold no debe arrastrar el dominio SIM (ICCID, NOS, Objenious) ni la infraestructura de mensajería/persistencia — solo el esqueleto del monorepo, el tooling y la configuración de agente.
|
||||
|
||||
## Decisiones de diseño
|
||||
|
||||
Ya validadas con el usuario antes de escribir este spec.
|
||||
|
||||
| # | Decisión | Elegido | Alternativas descartadas |
|
||||
|---|---|---|---|
|
||||
| 1 | Alcance | **A — Esqueleto mínimo** | B (con shared genérico), C (stack completo con Docker/RabbitMQ/Postgres) |
|
||||
| 2 | Documentación | **C — Híbrido** | A (genericizar todo), B (copiar tal cual) |
|
||||
| 3 | `git init` + commit inicial | **Sí** | — |
|
||||
| 4 | `amqplib` en `_template` | **Quitar** | Dejarla por si se usa después |
|
||||
| 5 | Sección EVENTS-RABBITMQ del auditor | **Mantener** | Quitar |
|
||||
| 6 | Carpeta `aplication` (typo) | **Pasar a `application`** | Mantener typo, dejarlo abierto |
|
||||
|
||||
## Alcance
|
||||
|
||||
### Lo que incluye `base-backend`
|
||||
|
||||
```
|
||||
base-backend/
|
||||
├── .agents/
|
||||
│ └── skills/sf-backend-architecture/ # copia íntegra + nota inicial
|
||||
├── .claude/
|
||||
│ ├── commands/ # /audit, /check, /md-lint
|
||||
│ ├── rules/
|
||||
│ │ ├── code-style.md # genericizado
|
||||
│ │ └── git-conventions.md # genericizado
|
||||
│ ├── skills/ # symlink clean-ddd-hexagonal y demás
|
||||
│ └── settings.local.json
|
||||
├── packages/
|
||||
│ └── _template/
|
||||
│ ├── config/env/index.ts
|
||||
│ ├── index.ts
|
||||
│ ├── package.json # name "template", sin amqplib
|
||||
│ ├── tsconfig.json
|
||||
│ └── .env
|
||||
├── docs/
|
||||
│ └── superpowers/specs/ # vacía, lista para nuevos specs
|
||||
├── .editorconfig
|
||||
├── .gitattributes
|
||||
├── .gitignore
|
||||
├── .yarnrc.yml
|
||||
├── CLAUDE.md # genericizado
|
||||
├── CONTRIBUTING.md # genericizado
|
||||
├── README.md # nuevo
|
||||
├── package.json # name "base-backend", workspaces
|
||||
├── tsconfig.json
|
||||
├── tsconfig.vitest.json
|
||||
└── vitest.config.ts
|
||||
```
|
||||
|
||||
### Lo que NO se copia
|
||||
|
||||
- `deployment/` (Docker compose, Dockerfile.dev, migraciones).
|
||||
- `imgs/` (diagrama `diagrama-rabbit.png`).
|
||||
- `rabbitmq_plugins/`.
|
||||
- Scripts `build.local.sh`, `run.local.sh`, `stop.local.sh`.
|
||||
- `.understand-anything/` (knowledge graph generado para sf-sim).
|
||||
- `skills-lock.json` (decisión: regenerar al instalar superpowers en el nuevo repo).
|
||||
- `yarn.lock` (se regenera con `yarn install` al primer uso).
|
||||
- Todos los packages de `packages/` excepto `_template/`.
|
||||
- `docs/sim-api-documentation.html` y `docs/sim-objenious/*` (específicos).
|
||||
|
||||
## Cambios sobre archivos copiados
|
||||
|
||||
### 1. `package.json` (raíz)
|
||||
|
||||
- `name`: `"sf-sim"` → `"base-backend"`.
|
||||
- `description`: actualizar a "Monorepo backend TypeScript ESM con DDD/Hexagonal — base para servicios nuevos".
|
||||
- `scripts`: mantener `dev`, `build`, `start`, `typecheck`, `lint`, `lint:fix`, `format`, `format:check`, `test`.
|
||||
- **Quitar** script `migrate` y dependencia `@sf-alvar/db-migrate` (no hay migrations en el scaffold).
|
||||
- Mantener `workspaces: ["packages/*"]`, `packageManager: "yarn@4.x"`.
|
||||
|
||||
### 2. `CLAUDE.md`
|
||||
|
||||
Reescritura completa. Mantiene la espina dorsal arquitectónica; quita lo específico de SIM.
|
||||
|
||||
**Mantiene**:
|
||||
- Sección "Comandos" con yarn dev/build/start/typecheck/lint/format/test/vitest run.
|
||||
- ESM puro, imports `.js` aunque el archivo sea `.ts`, path aliases por servicio.
|
||||
- Arquitectura: monorepo de microservicios DDD/Hexagonal con `domain/`, `application/`, `infrastructure/`, `config/`.
|
||||
- `Result<E, D>` para fallos esperables, `throw` para invariantes rotas.
|
||||
- DI manual por constructor con objeto `args: { dep1, dep2 }`. Tipos apuntando al port, no al adapter.
|
||||
- Skill `sf-backend-architecture` y referencias.
|
||||
- `@.claude/rules/code-style.md` y `@.claude/rules/git-conventions.md`.
|
||||
|
||||
**Quita**:
|
||||
- Comandos relativos a Docker (`./build.local.sh`, `./run.local.sh`, `./stop.local.sh`).
|
||||
- Comando `yarn migrate`.
|
||||
- Bloque RabbitMQ (exchange, routing keys, retries, DLX, correlation_id, diagrama).
|
||||
- Listado de packages (`sim-shared`, `sim-entrada-eventos`, `sim-consumidor-nos`, `sim-consumidor-objenious`, `sim-objenious-cron`).
|
||||
- Mención al typo `aplication` (en `base-backend` será `application`).
|
||||
|
||||
**Sustituye** la lista de packages por:
|
||||
> `packages/_template/` — plantilla oficial para servicios nuevos. Cópiala, renómbrala y desarrolla desde ahí.
|
||||
|
||||
### 3. `.claude/rules/code-style.md`
|
||||
|
||||
Quita la sección "Excepciones de este repo" entera. El base no tiene los pasivos legacy de sf-sim:
|
||||
|
||||
- No hay cobertura ~12%: la regla TDD + 70% aplica desde el día 1 (estricta para código nuevo).
|
||||
- No existe `sim-objenious-cron`: no hay excepciones arquitectónicas que documentar.
|
||||
- Carpeta `aplication`: deja de ser excepción porque pasamos a `application`.
|
||||
|
||||
Resto del fichero se mantiene tal cual.
|
||||
|
||||
### 4. `.claude/rules/git-conventions.md`
|
||||
|
||||
Quita el bloque "Pull Requests" entero (Gitea self-hosted, reviewer Alvar San Martin, dominio `savefamilygps.com`). Sustituye por:
|
||||
|
||||
```markdown
|
||||
## Pull Requests
|
||||
|
||||
Adapta este apartado al hosting y revisor de tu proyecto (GitHub, GitLab, Gitea, etc.).
|
||||
```
|
||||
|
||||
Mantiene reglas de branches y conventional commits.
|
||||
|
||||
### 5. `CONTRIBUTING.md`
|
||||
|
||||
Barrido para quitar:
|
||||
- Referencias a SIM, ICCID, RabbitMQ.
|
||||
- Reviewer concreto y dominio de email.
|
||||
- Comandos Docker locales.
|
||||
|
||||
Mantiene reglas generales de contribución (commits, PRs, code style).
|
||||
|
||||
### 6. `README.md` (nuevo)
|
||||
|
||||
Breve. Explica:
|
||||
- Qué es: monorepo backend TypeScript ESM con DDD/Hexagonal, yarn 4 workspaces.
|
||||
- Cómo arrancar: `yarn install`, `yarn dev`.
|
||||
- Cómo crear un servicio nuevo: copiar `packages/_template/`, renombrar.
|
||||
- Dónde mirar: `CLAUDE.md`, `CONTRIBUTING.md`, skill `sf-backend-architecture`.
|
||||
|
||||
### 7. `packages/_template/`
|
||||
|
||||
Limpiar el template completo de referencias específicas del repo origen.
|
||||
|
||||
#### 7.1 `package.json`
|
||||
|
||||
- `name`: `"sim-template"` → `"template"`.
|
||||
- `description`: "Template de la estructura de archivos" → "Plantilla base para servicios del monorepo".
|
||||
- **Quitar dependencias** `amqplib` y `@types/amqplib` (decisión 4).
|
||||
- Path aliases (`imports`): consolidar con los del `tsconfig.json` del template (sf-sim los tiene desincronizados). El conjunto canónico es: `#config/*`, `#adapters/*`, `#application/*`, `#domain/*`, `#ports/*`, `#tests/*`. (`#application/*` se añade nuevo — refleja la corrección del typo de decisión 6.)
|
||||
- Resto se mantiene.
|
||||
|
||||
#### 7.2 `tsconfig.json`
|
||||
|
||||
- `compilerOptions.outDir`: `"../../dist/sim-gestor-eventos"` → `"../../dist/template"` (nombre genérico).
|
||||
- `compilerOptions.paths`: añadir `"#application/*": ["application/*"]` para alinear con los `imports` del `package.json`.
|
||||
|
||||
#### 7.3 `.env`
|
||||
|
||||
Reescribir. Eliminar todas las vars de `RABBITMQ_*` y `POSTGRES_*` (decisión 1: sin infra de mensajería/persistencia en el scaffold). Dejar solo:
|
||||
|
||||
```env
|
||||
ENVIRONMENT=development
|
||||
```
|
||||
|
||||
(Corregido el typo `ENVIORMENT` del fichero original.)
|
||||
|
||||
#### 7.4 `config/env/index.ts`
|
||||
|
||||
Reescribir. Sustituir el objeto que exporta vars de RabbitMQ y Postgres por un objeto mínimo:
|
||||
|
||||
```ts
|
||||
export const env = {
|
||||
ENVIRONMENT: String(process.env.ENVIRONMENT ?? "development"),
|
||||
};
|
||||
```
|
||||
|
||||
Cuando alguien arranque un servicio real, ampliará este objeto con las vars que necesite.
|
||||
|
||||
### 8. `.agents/skills/sf-backend-architecture/SKILL.md`
|
||||
|
||||
Añadir bloque al inicio (tras el frontmatter):
|
||||
|
||||
> **Nota**: esta skill describe el estilo arquitectónico originado en el repo sf-sim (RabbitMQ + Postgres + DDD/Hexagonal + EDA). Los ejemplos están escritos contra ese dominio (SIM/ICCID/Objenious). Cuando la apliques en un repo distinto, **adapta los ejemplos** al dominio e infraestructura concretos — la arquitectura subyacente sigue valiendo.
|
||||
|
||||
Las referencias en `references/` (HOUSE-STYLE, CODE-STYLE, ANTI-PATTERNS, AUDIT-CHECKLIST, EVENTS-RABBITMQ) se copian sin tocar. La sección EVENTS-RABBITMQ del checklist del auditor se mantiene (decisión 5): si en el futuro se añade RabbitMQ al `base-backend`, la sección está disponible.
|
||||
|
||||
### 9. `.gitignore`
|
||||
|
||||
Verificar que no arrastra patrones específicos de sf-sim. Si los hay (poco probable; suele ser estándar `node_modules/`, `dist/`, `.env`), eliminarlos.
|
||||
|
||||
### 10. `tsconfig.json` (raíz)
|
||||
|
||||
Limpiar `compilerOptions.paths`. En sf-sim contiene una entrada hardcoded:
|
||||
|
||||
```json
|
||||
"paths": {
|
||||
"sim-consumidor-objenious": ["./packages/sim-consumidor-objenious/*"]
|
||||
}
|
||||
```
|
||||
|
||||
En `base-backend` esa entrada se elimina (deja `paths: {}` o se quita la clave entera). Resto del fichero se mantiene (`composite`, `module: nodenext`, `outDir: dist`, etc.).
|
||||
|
||||
### 11. `tsconfig.vitest.json` y `vitest.config.ts`
|
||||
|
||||
Se copian tal cual. Nota: `tsconfig.vitest.json` extiende `./tsconfig.app.json` que **no existe en sf-sim** (es un bug latente del repo origen). No se corrige aquí — corregirlo es responsabilidad de quien arranque el primer proyecto si llega a usar vitest a nivel raíz; mientras tanto los tests por package siguen funcionando como en sf-sim.
|
||||
|
||||
### 12. `application` vs `aplication`
|
||||
|
||||
Donde la documentación o el código mencione `aplication`, sustituir por `application`. Lugares conocidos:
|
||||
|
||||
- `CLAUDE.md` — la mención al typo se elimina (no se sustituye, deja de ser relevante).
|
||||
- `.agents/skills/sf-backend-architecture/SKILL.md` y referencias — buscar y sustituir.
|
||||
- `.agents/skills/sf-backend-architecture/references/HOUSE-STYLE.md`.
|
||||
- Comandos `/audit` y `/check` si lo mencionan.
|
||||
- `packages/_template/` — añadir entrada `#application/*` en path aliases.
|
||||
|
||||
El plan de implementación cerrará con un `grep -ri 'aplication' .` en el repo nuevo para garantizar que no queda ninguna mención.
|
||||
|
||||
## Proceso de implementación (resumen)
|
||||
|
||||
El plan detallado lo generará la skill `writing-plans` después de la aprobación de este spec. A alto nivel:
|
||||
|
||||
1. Crear `~/code/ref/base-backend/` y subdirectorios.
|
||||
2. Copiar archivos seleccionados de sf-sim.
|
||||
3. Aplicar las modificaciones (puntos 1-10 de la sección anterior).
|
||||
4. `git init` en `~/code/ref/base-backend`.
|
||||
5. `yarn install` para regenerar `yarn.lock`.
|
||||
6. Verificar que `yarn typecheck` y `yarn lint` pasan en el repo recién creado (típicamente vacío de errores en un scaffold).
|
||||
7. Commit inicial: `chore: scaffold base-backend monorepo`.
|
||||
|
||||
## Verificación de aceptación
|
||||
|
||||
El scaffold se considera completo cuando:
|
||||
|
||||
- [ ] `~/code/ref/base-backend` existe como repo git con commit inicial.
|
||||
- [ ] `yarn install` desde la raíz se ejecuta sin errores.
|
||||
- [ ] `yarn typecheck` pasa (puede no haber código en `_template/index.ts` más allá del placeholder).
|
||||
- [ ] `yarn lint` pasa.
|
||||
- [ ] No quedan referencias literales a "sim", "ICCID", "Objenious", "NOS", "RabbitMQ", "Postgres" en CLAUDE.md, CONTRIBUTING.md, README.md ni en `.claude/rules/*` (las menciones en las skills/referencias son intencionales por la nota añadida).
|
||||
- [ ] No queda `aplication` como typo en ningún fichero del repo nuevo.
|
||||
- [ ] Ejecutar `/audit` o `/check` desde Claude Code en `base-backend` carga sin errores y aplica al repo limpio.
|
||||
|
||||
## Fuera de alcance
|
||||
|
||||
- Crear servicios reales en `packages/`. El scaffold solo trae `_template`.
|
||||
- Configurar CI (GitHub Actions, Gitea Actions, etc.). Lo decide quien arranque el primer proyecto.
|
||||
- Migraciones de BBDD, Docker compose, scripts locales. Aplazado a una futura ampliación si se quiere una variante "stack completo".
|
||||
- Push del scaffold a un remoto. El usuario lo decide cuando lo use.
|
||||
1843
package-lock.json
generated
1843
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -7,11 +7,11 @@
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest watch",
|
||||
"build": "yarn workspaces foreach -A --exclude sim-consumidor-nos run build && cp .env dist/ && yarn setup:runtime",
|
||||
"build": "rm -rf ./dist && yarn workspaces foreach -Api run build && yarn setup:runtime",
|
||||
"setup:runtime": "mkdir -p dist/packages/node_modules && ln -sf ../sim-shared dist/packages/node_modules/sim-shared && ln -sf ../sf-consumidor-objenious dist/packages/node_modules/sim-consumidor-objenious",
|
||||
"start": "yarn setup:runtime && yarn workspaces foreach -Apiv --exclude sim-consumidor-nos run start",
|
||||
"start": "yarn workspaces foreach -Apiv run start",
|
||||
"typecheck": "npx tsc --noEmit",
|
||||
"dev": "yarn workspaces foreach -Apiv --exclude sim-consumidor-nos run dev ",
|
||||
"dev": "yarn workspaces foreach -Apiv run dev",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"format": "prettier --write .",
|
||||
@@ -19,16 +19,16 @@
|
||||
"migrate": "yarn db-migrate -e .env -m deployment/database/migrations -t 99.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sf-alvar/db-migrate": "1.0.6",
|
||||
"@tsconfig/node22": "^22.0.5",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^0.10.9",
|
||||
"axios": "^1.13.3",
|
||||
"cors": "^2.8.5",
|
||||
"db-migrate": "https://git.savefamilygps.net/alvarsanmartin/herramienta-migracion.git",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"pg": "^8.18.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"uuidv7": "^1.1.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-tsconfig-paths": "^6.0.5"
|
||||
|
||||
1
packages/_template/config/env/index.ts
vendored
1
packages/_template/config/env/index.ts
vendored
@@ -1,5 +1,4 @@
|
||||
export const env = {
|
||||
ENVIRONMENT: process.env.ENVIORMENT,
|
||||
POSTGRES_USER: process.env.POSTGRES_USER,
|
||||
POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD,
|
||||
POSTGRES_PORT: process.env.POSTGRES_PORT,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
PORT=3000
|
||||
RABBITMQ_USER=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
|
||||
ENVIORMENT=development
|
||||
|
||||
RABBITMQ_HOST=rabbitmq-sim-broker
|
||||
#RABBITMQ_HOST=localhost
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_USER=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
RABBITMQ_SECURE=false
|
||||
RABBITMQ_VHOST=sim-vhost
|
||||
|
||||
# Hay cosas que unificar de varios servicios
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_DATABASE=postres
|
||||
POSTGRES_HOST=postgresql-sim-1
|
||||
POSTGRES_PORT=5432
|
||||
DEV_POSTGRES_PORT=5432
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=1234
|
||||
@@ -1,65 +1,178 @@
|
||||
import { EventBus } from "sim-shared/domain/EventBus.port.js";
|
||||
import { ConsumeMessage } from "amqplib";
|
||||
import { Request, Response } from "express"
|
||||
import { SimNosUsecases } from "./SimNOS.usecases.js";
|
||||
import { EventBus } from "sim-shared/domain/EventBus.port.js";
|
||||
import { Result } from "sim-shared/domain/Result.js";
|
||||
import { SimEvents } from "sim-shared/domain/SimEvents.js";
|
||||
import { iccidValidator } from "./httpValidators.js";
|
||||
|
||||
export class SimNosController {
|
||||
private eventBus: EventBus;
|
||||
private activationUseCases: any;
|
||||
|
||||
private routes = new Map<string, () => void>([
|
||||
["activate", async () => { console.log("caso de uso activate") }],
|
||||
["pause", async () => { console.log("caso de uso pause") }],
|
||||
["cancel", async () => { console.log("caso de uso cancel") }],
|
||||
])
|
||||
|
||||
constructor(
|
||||
eventBus: EventBus
|
||||
private uscases: SimNosUsecases,
|
||||
private eventBus: EventBus,
|
||||
) {
|
||||
this.eventBus = eventBus
|
||||
|
||||
// No se si hay un sistema mejor
|
||||
// convertor en const () => {} para conservar el contexto??
|
||||
this.recibeMsg = this.recibeMsg.bind(this)
|
||||
}
|
||||
|
||||
public async recibeMsg(msg: ConsumeMessage | null) {
|
||||
if (!this.validateActivationMsg(msg)) {
|
||||
throw new Error("Error consumiendo el mensaje no es valido")
|
||||
}
|
||||
private validateMsg(msg: ConsumeMessage | null) {
|
||||
if (msg == undefined) return false;
|
||||
const msgData = this.decodeMsg(msg) as SimEvents.general
|
||||
if (msgData == undefined || msgData.payload == undefined) throw new Error("Mensaje invalido")
|
||||
return msgData;
|
||||
}
|
||||
|
||||
msg = msg!
|
||||
|
||||
const msgParsed = JSON.parse(String(msg.content))
|
||||
const msgKey = msg.fields.routingKey.split(".")
|
||||
const accion = msgKey[2]
|
||||
|
||||
if (accion == undefined) {
|
||||
console.error("La routingKey es incorrecta: " + accion)
|
||||
this.eventBus.nack(msg)
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.routes.get(accion) == undefined) {
|
||||
console.error("No hay una ruta definida para la accion")
|
||||
this.eventBus.nack(msg)
|
||||
return;
|
||||
private decodeMsg(msg: ConsumeMessage): object | undefined {
|
||||
if (msg.content == undefined) {
|
||||
console.warn('[Sim.controller] Mensaje vacío');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
this.routes.get(accion)!()
|
||||
} catch (err) {
|
||||
console.log("Error procesando el mensaje")
|
||||
this.eventBus.nack(msg)
|
||||
} finally {
|
||||
this.eventBus.ack(msg)
|
||||
// Convertir el Buffer a String (UTF-8)
|
||||
const contentJson = JSON.parse(Buffer.from(msg.content).toString('utf8'))
|
||||
return contentJson;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error al decodificar JSON:', error);
|
||||
console.error(Buffer.from(msg.content).toString(("utf8")))
|
||||
// Aquí podrías decidir devolver el string crudo o null
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - Loguear motivos de la no validacion
|
||||
* Metodo duplicado se puede generalizar la a una clase sharedController con las funciones basicas
|
||||
*/
|
||||
private async tryUseCase<T extends any>
|
||||
(msg: ConsumeMessage, usecase: () => Promise<Result<string, T>>): Promise<Result<string, T>> {
|
||||
try {
|
||||
const result = await usecase()
|
||||
if (result.error == undefined) {
|
||||
await this.eventBus.ack(msg)
|
||||
return result
|
||||
} else {
|
||||
console.error("Error procesando el caso de uso (NOS)", result.error)
|
||||
this.eventBus.nack(msg)
|
||||
return result
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error general procesando el caso de uso (NOS)")
|
||||
this.eventBus.nack(msg)
|
||||
return {
|
||||
error: String(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public activate() {
|
||||
return async (msg: ConsumeMessage) => {
|
||||
console.log("[i] Evento activate ", msg.fields)
|
||||
const data = this.validateMsg(msg) as SimEvents.activation
|
||||
const iccid = data.payload.iccid
|
||||
const correlation_id = data.headers?.message_id
|
||||
const res = await this.tryUseCase(msg, this.uscases.activate({
|
||||
iccid: iccid,
|
||||
correlation_id: correlation_id
|
||||
}))
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
public suspend() {
|
||||
return async (msg: ConsumeMessage) => {
|
||||
console.log("Evento suspend ", msg.fields)
|
||||
const data = this.validateMsg(msg) as SimEvents.suspend
|
||||
const iccid = data.payload.iccid
|
||||
const correlation_id = data.headers?.message_id
|
||||
const res = await this.tryUseCase(msg, this.uscases.suspend({
|
||||
iccid: iccid,
|
||||
correlation_id: correlation_id
|
||||
}))
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
public terminate() {
|
||||
return async (msg: ConsumeMessage) => {
|
||||
console.log("Evento termiante no soportado ", msg.fields)
|
||||
}
|
||||
}
|
||||
|
||||
public reActivate() {
|
||||
return async (msg: ConsumeMessage) => {
|
||||
console.log("Evento reActivate ", msg.fields)
|
||||
const data = this.validateMsg(msg) as SimEvents.reActivation
|
||||
const iccid = data.payload.iccid
|
||||
const correlation_id = data.headers?.message_id
|
||||
const res = await this.tryUseCase(msg, this.uscases.reactivate({
|
||||
iccid: iccid,
|
||||
correlation_id: correlation_id
|
||||
}))
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select especificamente por REST para evitar pasar por las colas.
|
||||
* La respuesta es instantanea no se tiene que registrar como operación.
|
||||
*/
|
||||
private validateActivationMsg(msg: ConsumeMessage | null) {
|
||||
if (msg == undefined) return false;
|
||||
return true;
|
||||
public selectREST() {
|
||||
return async (req: Request, res: Response) => {
|
||||
const { query } = req
|
||||
const body = { iccid: query.iccid as string }
|
||||
console.log("Evento select", body)
|
||||
const validateBody = iccidValidator.validate(body);
|
||||
|
||||
if (validateBody.error != undefined) {
|
||||
res.status(402).json(validateBody)
|
||||
return;
|
||||
}
|
||||
|
||||
const iccid: string | string[] = body.iccid
|
||||
|
||||
if (Array.isArray(iccid)) {
|
||||
// TODO: Automatizar la paginacion
|
||||
//const usecaseRes = this.uscases.selectMany({ iccid })
|
||||
} else {
|
||||
const usecaseRes = await this.uscases.selectOne({ iccid })
|
||||
if (usecaseRes.error != undefined) {
|
||||
res.status(500).json(usecaseRes)
|
||||
return;
|
||||
} else {
|
||||
res.send(usecaseRes.data)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json(validateBody)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public selectPageREST() {
|
||||
return async (req: Request, res: Response) => {
|
||||
const { offset, limit, filter, orderBy } = req.query
|
||||
const params = {
|
||||
offset: (offset != undefined) ? Number(offset) : undefined,
|
||||
limit: (limit != undefined) ? Number(limit) : undefined,
|
||||
filter: (filter != undefined) ? String(filter) : undefined,
|
||||
orderBy: (orderBy != undefined) ? String(orderBy) : undefined
|
||||
}
|
||||
|
||||
const usecaseRes = await this.uscases.selectPage(params)
|
||||
|
||||
if (usecaseRes.error != undefined) {
|
||||
res.status(500).json(usecaseRes)
|
||||
return;
|
||||
} else {
|
||||
res.status(200).send(usecaseRes.data)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
79
packages/sim-consumidor-nos/aplication/SimNOS.router.ts
Normal file
79
packages/sim-consumidor-nos/aplication/SimNOS.router.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Dirige cada mensaje dependiendo de el tipo de acción que contenga
|
||||
* Podría hacerse con varias colas, pero así se controla mejor que
|
||||
* las operaciones se hagan de 1 en 1.
|
||||
*/
|
||||
|
||||
import { ConsumeMessage } from "amqplib";
|
||||
import { SimNosController } from "./SimNOS.controller.js";
|
||||
import { EventBus } from "sim-shared/domain/EventBus.port.js";
|
||||
import { Result } from "sim-shared/domain/Result.js";
|
||||
|
||||
type FuncType = ((m: ConsumeMessage) => Promise<Result<string, any>>)
|
||||
|
||||
export class SimNosRouter {
|
||||
private readonly routes: Map<string, FuncType>;
|
||||
|
||||
constructor(
|
||||
private readonly simController: SimNosController,
|
||||
private readonly eventBus: EventBus
|
||||
) {
|
||||
this.routes = new Map<string, FuncType>([
|
||||
//["select", undefined],
|
||||
["activate", this.simController.activate()],
|
||||
["pause", this.simController.suspend()],
|
||||
["reactivate", this.simController.reActivate()],
|
||||
//["cancel", this.simController.terminate()],
|
||||
//["preActivate", this.simController.preActivate()]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enruta el mensaje a la acción correspondiente basándose en la routing key
|
||||
* TODO: No estoy seguro que deba meter el nack aqui
|
||||
* - De moemento el ack-nack se gestiona en los controller, por si acaso hay casos
|
||||
* limite en
|
||||
*/
|
||||
public route = async (msg: ConsumeMessage | null): Promise<void> => {
|
||||
if (!msg) {
|
||||
console.error("[Router] Mensaje vacío");
|
||||
return;
|
||||
}
|
||||
|
||||
const action = this.extractAction(msg);
|
||||
|
||||
if (!action) {
|
||||
console.error("[Router] La routing key no tiene una acción definida", msg.fields.routingKey);
|
||||
this.eventBus.nack(msg)
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = this.routes.get(action);
|
||||
|
||||
if (!handler) {
|
||||
console.error(`[Router] La acción '${action}' no tiene un controlador asociado`);
|
||||
this.eventBus.nack(msg)
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[Router] Ejecutando operación:", action);
|
||||
|
||||
// El controlador devuelve una función (thunk) que debe ser ejecutada
|
||||
const executeParams = handler(msg);
|
||||
|
||||
if (typeof executeParams === "function") {
|
||||
const res = await executeParams;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[Router] Error al ejecutar la operación '${action}':`, error);
|
||||
this.eventBus.nack(msg)
|
||||
}
|
||||
};
|
||||
|
||||
private extractAction(msg: ConsumeMessage): string | undefined {
|
||||
// Se asume que la acción está en la tercera posición: domain.compañia.accion
|
||||
return msg.fields.routingKey.split(".")[2];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Documentación de referencia:
|
||||
* https://pelion-help.iot-x.com/nos/en-US/Content/API/APIReference/API%20Reference.htm?tocpath=_____7
|
||||
*
|
||||
* En nos el correlation_id ya va a ser obligatorio en todos los mensajes
|
||||
*
|
||||
* TODO:
|
||||
* - Control de errores más preciso
|
||||
*
|
||||
*/
|
||||
import { NosHttpClient } from "#infrastructure/NosHttpClient.js";
|
||||
import { NosRepository } from "#infrastructure/NosRepository.js";
|
||||
import { ErrorOrderDTO, FinishOrderDTO, UpdateOrderDTO } from "sim-shared/domain/Order.js";
|
||||
import { Result } from "sim-shared/domain/Result.js";
|
||||
import { OrderRepository } from "sim-shared/infrastructure/OrderRepository.js";
|
||||
|
||||
export class SimNosUsecases {
|
||||
constructor(
|
||||
private httpClient: NosHttpClient,
|
||||
private nosRepository: NosRepository,
|
||||
private orderRepository: OrderRepository
|
||||
) {
|
||||
}
|
||||
|
||||
private async setRunning(correlation_id: string) {
|
||||
// En NOS el updateOrder se hace con el correlation_id que viene en la cabecera del
|
||||
// mensaje consumido
|
||||
const updateData: UpdateOrderDTO = {
|
||||
new_status: "running",
|
||||
correlation_id: correlation_id
|
||||
}
|
||||
const order = await this.orderRepository.updateOrder(updateData)
|
||||
return order
|
||||
}
|
||||
|
||||
private async setFinished(correlation_id: string) {
|
||||
// En NOS el updateOrder se hace con el correlation_id que viene en la cabecera del
|
||||
// mensaje consumido
|
||||
const updateData: FinishOrderDTO = {
|
||||
correlation_id: correlation_id
|
||||
}
|
||||
const order = await this.orderRepository.finishOrder(updateData)
|
||||
return order
|
||||
}
|
||||
|
||||
private async setFailed(correlation_id: string, reason: string, detail?: string) {
|
||||
// En NOS el updateOrder se hace con el correlation_id que viene en la cabecera del
|
||||
// mensaje consumido
|
||||
const updateData: ErrorOrderDTO = {
|
||||
status: "failed",
|
||||
correlation_id: correlation_id,
|
||||
reason: reason,
|
||||
error: reason,
|
||||
stackTrace: detail
|
||||
}
|
||||
|
||||
console.log("SET FAILED DATA:", updateData)
|
||||
const order = await this.orderRepository.errorOrder(updateData)
|
||||
console.log("SET FAILED RES:", order)
|
||||
return order
|
||||
}
|
||||
|
||||
public usecaseTemplate<T, R>(
|
||||
func: (_: T) => Promise<Result<string, R>>,
|
||||
args: T,
|
||||
correlation_id?: string | undefined
|
||||
) {
|
||||
return async () => {
|
||||
// Operacion pending -> running
|
||||
if (correlation_id != undefined)
|
||||
this.setRunning(correlation_id)
|
||||
.then()
|
||||
.catch(e => console.error("Error actualizando el order", e))
|
||||
|
||||
try {
|
||||
const res = await func(args)
|
||||
|
||||
if (res.error != undefined) {
|
||||
console.log("Error peticion: ", res, correlation_id)
|
||||
if (correlation_id != undefined)
|
||||
this.setFailed(correlation_id, res.error)
|
||||
.then(e => console.log("failed", e))
|
||||
.catch(e => console.error(e))
|
||||
return res;
|
||||
} else {
|
||||
if (correlation_id != undefined)
|
||||
this.setFinished(correlation_id).then()
|
||||
return res;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (correlation_id != undefined)
|
||||
this.setFailed(correlation_id, "Error general de operacion de SIM (NOS) ", String(e)).then()
|
||||
return {
|
||||
error: "Error general de operacion de SIM (NOS) " + String(e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public activate(args: {
|
||||
iccid: string,
|
||||
correlation_id?: string
|
||||
}) {
|
||||
return this.usecaseTemplate(
|
||||
(args) => this.nosRepository.activateSim(args), args.iccid, args.correlation_id)
|
||||
}
|
||||
|
||||
public suspend(args: {
|
||||
iccid: string,
|
||||
correlation_id?: string
|
||||
}) {
|
||||
return this.usecaseTemplate(
|
||||
(args) => this.nosRepository.bar(args), args.iccid, args.correlation_id)
|
||||
}
|
||||
|
||||
public reactivate(args: {
|
||||
iccid: string,
|
||||
correlation_id?: string
|
||||
}) {
|
||||
return this.usecaseTemplate(
|
||||
(args) => this.nosRepository.unbar(args), args.iccid, args.correlation_id)
|
||||
}
|
||||
|
||||
public terminate(args: { iccid: string }) {
|
||||
throw new Error("No hay termination para NOS")
|
||||
}
|
||||
|
||||
/* Importante: Las operaciones de lectua no dejan registro en orders */
|
||||
|
||||
public async selectOne(args: {
|
||||
iccid: string
|
||||
}) {
|
||||
const res = await this.nosRepository.getLineInfo(args.iccid)
|
||||
return res
|
||||
}
|
||||
|
||||
public async selectPage(args: {
|
||||
offset?: number,
|
||||
limit?: number,
|
||||
filter?: string,
|
||||
orderBy?: string
|
||||
}) {
|
||||
const res = await this.nosRepository.getLinePage(args)
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
public selectMany(args: {
|
||||
iccid: string[]
|
||||
}) {
|
||||
return {}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
39
packages/sim-consumidor-nos/aplication/httpValidators.ts
Normal file
39
packages/sim-consumidor-nos/aplication/httpValidators.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { BodyValidator, Validator } from "sim-shared/aplication/BodyValidator.js";
|
||||
|
||||
const iccidNotNull = <Validator<{ iccid: unknown }>>{
|
||||
field: "iccid",
|
||||
errorMsg: "El iccid no está definido",
|
||||
validationFunc: (a: { iccid: unknown }) => {
|
||||
return (a.iccid != null && a.iccid != undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const iccidValueOrArray = <Validator<{ iccid: unknown }>>{
|
||||
field: "iccid",
|
||||
errorMsg: "El iccid debe de ser un único valor o una lista",
|
||||
validationFunc: (a: { iccid: unknown }) => {
|
||||
return (typeof a.iccid == "string" || Array.isArray(a.iccid))
|
||||
}
|
||||
}
|
||||
|
||||
const iccidLongitudValidator = <Validator<{ iccid: string | string[] }>>{
|
||||
field: "iccid",
|
||||
errorMsg: "La longitud del iccid/s es incorrecta debera ser de 19 caracteres",
|
||||
validationFunc: (a: { iccid: string | string[] }) => {
|
||||
if (Array.isArray(a.iccid)) {
|
||||
const res = (a.iccid as string[]).filter(e => e.length != 19)
|
||||
if (res.length > 0) return false;
|
||||
} else {
|
||||
return (a.iccid as string).length == 19
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const iccidValidator = new BodyValidator<{ iccid: string | string[] }>(
|
||||
[
|
||||
iccidNotNull,
|
||||
iccidValueOrArray,
|
||||
iccidLongitudValidator,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { loadEnvFile } from "node:process";
|
||||
loadEnvFile("../../.env")
|
||||
import path from "node:path";
|
||||
|
||||
try {
|
||||
loadEnvFile(path.join("./.env")) // base
|
||||
} catch (e) {
|
||||
console.error("Error cargando el .env desde ./.env")
|
||||
}
|
||||
try {
|
||||
loadEnvFile(path.join("../../.env")) // Global
|
||||
} catch (e) {
|
||||
console.error("Error cargando el .env desde ../../.env")
|
||||
}
|
||||
|
||||
export const env = {
|
||||
ENVIRONMENT: process.env.ENVIORMENT,
|
||||
POSTGRES_USER: process.env.POSTGRES_USER,
|
||||
POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD,
|
||||
POSTGRES_PORT: process.env.POSTGRES_PORT,
|
||||
@@ -18,5 +28,12 @@ export const env = {
|
||||
RABBITMQ_SECURE: process.env.RABBITMQ_SECURE,
|
||||
RABBITMQ_RETRY_INTERVAL: process.env.RABBITMQ_INTERVAL,
|
||||
RABBITMQ_VHOST: String(process.env.RABBITMQ_VHOST),
|
||||
|
||||
APP_PORT: Number(process.env.APP_PORT),
|
||||
APP_HOST: String(process.env.APP_HOST),
|
||||
|
||||
// ESPECIFICO NOS
|
||||
NOS_BASE_URL: String(process.env.NOS_BASE_URL),
|
||||
NOS_ACCESS_TOKEN: String(process.env.NOS_ACCESS_TOKEN)
|
||||
};
|
||||
|
||||
72
packages/sim-consumidor-nos/config/eventBus.config.ts
Normal file
72
packages/sim-consumidor-nos/config/eventBus.config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { RabbitMQEventBus, RMQConnectionParams } from "sim-shared/infrastructure/RabbitMQEventBus.js"
|
||||
import { Channel } from "amqp-connection-manager"
|
||||
import { env } from "./env/env.js"
|
||||
|
||||
const rmqUser = env.RABBITMQ_USER
|
||||
const rmqPass = env.RABBITMQ_PASSWORD
|
||||
const rmqHost = env.RABBITMQ_HOST
|
||||
const rmqPort = Number(env.RABBITMQ_PORT)
|
||||
const rmqSecure = false
|
||||
const rmqVhost = env.RABBITMQ_VHOST
|
||||
|
||||
export const rmqConnOptions = <RMQConnectionParams>{
|
||||
username: rmqUser,
|
||||
password: rmqPass,
|
||||
vhost: rmqVhost,
|
||||
hostname: rmqHost,
|
||||
port: rmqPort,
|
||||
secure: rmqSecure,
|
||||
}
|
||||
|
||||
|
||||
const QUEUES = {
|
||||
NOS: "sim.nos",
|
||||
NOSDLX: "sim.nos.dlx",
|
||||
NOSDEL: "sim.nos.delayed",
|
||||
}
|
||||
|
||||
const EXCHANGES = {
|
||||
MAIN: "sim.exchange",
|
||||
DLX: "sim.ex.nos.dlx",
|
||||
DEL: "sim.ex.nos.delayed"
|
||||
}
|
||||
|
||||
export const rabbitmqEventBus = new RabbitMQEventBus({
|
||||
connectionParams: rmqConnOptions,
|
||||
buildStructure: buildQueues,
|
||||
maxRetry: 2,
|
||||
delayedExchange: EXCHANGES.DEL,
|
||||
dlxExchange: EXCHANGES.DLX
|
||||
})
|
||||
|
||||
async function buildQueues(channel: Channel) {
|
||||
|
||||
const DELAY = 10 * 1000
|
||||
const BASE_NOS_KEY = "sim.nos.#"
|
||||
|
||||
await channel.assertExchange(EXCHANGES.DEL, "topic")
|
||||
await channel.assertExchange(EXCHANGES.DLX, "topic")
|
||||
await channel.assertExchange(EXCHANGES.MAIN, "topic")
|
||||
|
||||
await channel.assertQueue(QUEUES.NOS)
|
||||
await channel.assertQueue(QUEUES.NOSDLX)
|
||||
await channel.assertQueue(QUEUES.NOSDEL, {
|
||||
durable: true,
|
||||
arguments: {
|
||||
'x-message-ttl': DELAY,
|
||||
'x-dead-letter-exchange': EXCHANGES.MAIN,
|
||||
}
|
||||
})
|
||||
|
||||
// Cola dead-letter
|
||||
await channel.bindQueue(QUEUES.NOSDLX, EXCHANGES.DLX, "sim.nos.#")
|
||||
// Cola delay
|
||||
await channel.bindQueue(QUEUES.NOSDEL, EXCHANGES.DEL, BASE_NOS_KEY)
|
||||
// Cola nos -> main exchange
|
||||
await channel.bindQueue(QUEUES.NOS, EXCHANGES.MAIN, BASE_NOS_KEY)
|
||||
}
|
||||
|
||||
export async function startRMQClient() {
|
||||
await rabbitmqEventBus.connect()
|
||||
return rabbitmqEventBus
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { RabbitMQEventBus, RMQConnectionParams } from "sim-shared/infrastructure/RabbitMQEventBus.js"
|
||||
import { env } from "./env"
|
||||
|
||||
const rmqUser = env.RABBITMQ_USER
|
||||
const rmqPass = env.RABBITMQ_PASSWORD
|
||||
const rmqHost = env.RABBITMQ_HOST
|
||||
const rmqPort = Number(env.RABBITMQ_PORT)
|
||||
const rmqSecure = false
|
||||
const rmqVhost = env.RABBITMQ_VHOST
|
||||
|
||||
export const rmqConnOptions = <RMQConnectionParams>{
|
||||
username: rmqUser,
|
||||
password: rmqPass,
|
||||
vhost: rmqVhost,
|
||||
hostname: rmqHost,
|
||||
port: rmqPort,
|
||||
secure: rmqSecure,
|
||||
}
|
||||
|
||||
export const rabbitmqEventBus = new RabbitMQEventBus({
|
||||
connectionParams: rmqConnOptions
|
||||
})
|
||||
|
||||
export async function startRMQClient() {
|
||||
await rabbitmqEventBus.connect().catch(async e => {
|
||||
console.error("Error en la conexion RMQ")
|
||||
await rabbitmqEventBus.connect()
|
||||
})
|
||||
|
||||
// Bindings especificos, deberia meterlos en la clase
|
||||
try {
|
||||
await rabbitmqEventBus.channel?.assertQueue("sim.nos")
|
||||
} catch {
|
||||
console.log("[i] Cola de sims de nos creada")
|
||||
await rabbitmqEventBus.channel?.bindQueue("sim.nos", "sim.exchange", "sim.nos.*")
|
||||
}
|
||||
|
||||
return rabbitmqEventBus
|
||||
}
|
||||
18
packages/sim-consumidor-nos/config/postgreConfig.ts
Normal file
18
packages/sim-consumidor-nos/config/postgreConfig.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import { PgClient } from 'sim-shared/infrastructure/PgClient.js'
|
||||
import { env } from './env/env.js';
|
||||
|
||||
// Configuracion de la conexion a la BDD, deberia ser la
|
||||
// Misma para todos los servicios pero hasta que se unifique todo
|
||||
// se hace una por servicio.
|
||||
export const pgPool = new Pool({
|
||||
user: env.POSTGRES_USER,
|
||||
host: env.POSTGRES_HOST,
|
||||
database: env.POSTGRES_DATABASE,
|
||||
password: env.POSTGRES_PASSWORD,
|
||||
port: Number(env.POSTGRES_PORT) || 5433,
|
||||
});
|
||||
|
||||
export const pgClient = new PgClient({
|
||||
pool: pgPool
|
||||
})
|
||||
131
packages/sim-consumidor-nos/domain/NosAPI.ts
Normal file
131
packages/sim-consumidor-nos/domain/NosAPI.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
export namespace NosApi {
|
||||
|
||||
export type ActivationData = {
|
||||
/**
|
||||
The unique physical subscriber identifier:
|
||||
Cellular - the ICCID
|
||||
Non - IP - the EUI
|
||||
Satellite - the IMEI
|
||||
*/
|
||||
physicalId: string,
|
||||
|
||||
/**
|
||||
example: 447000000001
|
||||
The unique network subscriber identifier:
|
||||
|
||||
Cellular subscriber - the MSISDN
|
||||
Non - IP subscriber - the Device EUI
|
||||
Satellite subscriber - the Subscription ID
|
||||
*/
|
||||
subscriberId: string,
|
||||
|
||||
/**
|
||||
example: 9999
|
||||
If the subscriber uses Circuit Switched Data(CSD), this field displays its data number.If the subscriber does not use CSD, this field is null.
|
||||
*/
|
||||
dataNumber: string
|
||||
|
||||
/**
|
||||
example: 172.0.0.1
|
||||
The subscriber IP address.
|
||||
*/
|
||||
ip: string
|
||||
|
||||
/**
|
||||
example: 234150000000001
|
||||
The subscriber IMSI.
|
||||
*/
|
||||
imsi: string
|
||||
|
||||
}
|
||||
|
||||
type OkResponse<T> = {
|
||||
error?: undefined | null,
|
||||
content: T
|
||||
}
|
||||
|
||||
type ErrorResponse<E = GeneralError> = {
|
||||
content?: undefined | null,
|
||||
error: E
|
||||
}
|
||||
|
||||
type GeneralError = {
|
||||
children?: string[],
|
||||
code: string,
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ActivateResponseOK = OkResponse<ActivationData>
|
||||
export type ActivateResponseError = ErrorResponse
|
||||
export type ActivateResponse = ActivateResponseOK | ActivateResponseError
|
||||
|
||||
export type LineDataResponseOK = OkResponse<LineData>
|
||||
export type LineDataResponseError = ErrorResponse
|
||||
export type LineDataResponse = LineDataResponseOK | LineDataResponseError
|
||||
|
||||
export type PageDataResponseOk = OkResponse<LineData[]>
|
||||
export type PageDataResponseError = OkResponse<LineData[]>
|
||||
export type PageResponse = PageDataResponseOk | PageDataResponseError
|
||||
|
||||
export type LineData = {
|
||||
physicalId: string
|
||||
subscriberId: string
|
||||
imsi: string
|
||||
nickname: string
|
||||
operatorCode: string
|
||||
tariffName: string
|
||||
lineRental: number
|
||||
contractLength: number
|
||||
isBarred: boolean
|
||||
isActive: boolean
|
||||
terminateDate: any
|
||||
groupId: number
|
||||
subscriberType: any
|
||||
connectionDate: string
|
||||
expiryDate: string
|
||||
networkState: NetworkState
|
||||
billingState: BillingState
|
||||
operatorName: string
|
||||
imei: string
|
||||
dataUsage?: number
|
||||
eid?: string
|
||||
smdpProvider?: string
|
||||
related?: {
|
||||
parent: RelatedItem,
|
||||
profiles: RelatedItem[]
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = {
|
||||
physicalId: string
|
||||
isEnabledOnParent: boolean
|
||||
}
|
||||
|
||||
export type NetworkState = {
|
||||
currentStateId: number
|
||||
currentState: string
|
||||
isTransferring: boolean
|
||||
lastTransferred: number
|
||||
isOnline: boolean
|
||||
lastSeenOnline: number
|
||||
}
|
||||
|
||||
export type BillingState = {
|
||||
currentStateId: number
|
||||
currentState: string
|
||||
}
|
||||
|
||||
export type BarData = {
|
||||
product: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type BarResponseOk = OkResponse<BarData>
|
||||
export type BarResponseError = ErrorResponse
|
||||
export type BarResponse = BarResponseOk | BarResponseError
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,74 @@
|
||||
|
||||
import { startRMQClient } from "#config/eventBusConfig"
|
||||
import express from "express"
|
||||
import cors from 'cors';
|
||||
import { SimNosRouter } from "./aplication/SimNOS.router.js"
|
||||
import { SimNosController } from "./aplication/SimNOS.controller.js"
|
||||
import { SimNosUsecases } from "./aplication/SimNOS.usecases.js"
|
||||
import { NosHttpClient } from "./infrastructure/NosHttpClient.js"
|
||||
import { env } from "#config/env/env.js"
|
||||
import { NosRepository } from "./infrastructure/NosRepository.js"
|
||||
import { OrderRepository } from "sim-shared/infrastructure/OrderRepository.js";
|
||||
import { pgClient } from "#config/postgreConfig.js";
|
||||
import { startRMQClient } from "#config/eventBus.config.js";
|
||||
|
||||
const RMQ_QUEUE = "sim.nos"
|
||||
const NOS_BASE_URL = env.NOS_BASE_URL
|
||||
const PORT = env.APP_PORT
|
||||
const HOSTNAME = env.APP_HOST
|
||||
|
||||
async function startWorker() {
|
||||
// Instancia de dependencias
|
||||
|
||||
const rmqClient = await startRMQClient()
|
||||
const nosHttpClient = new NosHttpClient(
|
||||
NOS_BASE_URL
|
||||
)
|
||||
|
||||
const nosRepository = new NosRepository(
|
||||
nosHttpClient
|
||||
)
|
||||
|
||||
const orderRepository = new OrderRepository(
|
||||
pgClient
|
||||
)
|
||||
|
||||
const simUsecases = new SimNosUsecases(
|
||||
nosHttpClient,
|
||||
nosRepository,
|
||||
orderRepository
|
||||
)
|
||||
|
||||
const simController = new SimNosController(
|
||||
simUsecases,
|
||||
rmqClient
|
||||
)
|
||||
|
||||
rmqClient.consume("sim.nos", simController.recibeMsg)
|
||||
const simRouter = new SimNosRouter(
|
||||
simController,
|
||||
rmqClient
|
||||
)
|
||||
|
||||
// RMQ
|
||||
rmqClient.consume(RMQ_QUEUE, simRouter.route)
|
||||
.then(() => console.log("Cliente rmq creado con exito"))
|
||||
.catch(e => console.error("Error conectando con RABBITMQ", e))
|
||||
|
||||
// Express
|
||||
const app = express()
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.get("/select", simController.selectREST())
|
||||
app.get("/selectPage", simController.selectPageREST())
|
||||
|
||||
app.listen(PORT, HOSTNAME, (e) => {
|
||||
if (e == undefined) {
|
||||
console.log("[o] Servidor iniciado en el puerto %d", PORT)
|
||||
} else {
|
||||
console.error("Error express ", e)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
startWorker()
|
||||
|
||||
41
packages/sim-consumidor-nos/infrastructure/NosHttpClient.ts
Normal file
41
packages/sim-consumidor-nos/infrastructure/NosHttpClient.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { env } from "#config/env/env.js"
|
||||
|
||||
export class NosHttpClient {
|
||||
public client: AxiosInstance;
|
||||
|
||||
constructor(
|
||||
private baseURL: string,
|
||||
//private jwtManager: JWTProvider<any>
|
||||
) {
|
||||
this.client = axios.create({
|
||||
baseURL: baseURL
|
||||
})
|
||||
|
||||
// Interceptor para los headers fijos
|
||||
this.client.interceptors.request.use(
|
||||
async (config) => {
|
||||
// Configuracion especifica de NOS (El token simepre es el mismo?)
|
||||
const token = env.NOS_ACCESS_TOKEN;
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
config.headers.set("content-type", "application/json")
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
}
|
||||
|
||||
get post() {
|
||||
return this.client.post
|
||||
}
|
||||
|
||||
get patch() {
|
||||
return this.client.patch
|
||||
}
|
||||
|
||||
get get() {
|
||||
return this.client.get
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
177
packages/sim-consumidor-nos/infrastructure/NosRepository.ts
Normal file
177
packages/sim-consumidor-nos/infrastructure/NosRepository.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Result } from "sim-shared/domain/Result.js";
|
||||
import { NosHttpClient } from "./NosHttpClient.js";
|
||||
import { NosApi } from "#domain/NosAPI.js";
|
||||
import axios, { AxiosError, AxiosResponse } from "axios";
|
||||
|
||||
export class NosRepository {
|
||||
constructor(
|
||||
private httpClient: NosHttpClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* E => Tipo de error
|
||||
* T => Tipo de dato para cod 200
|
||||
*
|
||||
* TODO:
|
||||
* - Mejor gestion de los errores
|
||||
* - E no se aplica todavia por no hacer la transformacion del error
|
||||
*/
|
||||
private async manageNosRequest<E, T>(promise: Promise<AxiosResponse<T>>): Promise<Result<string, T>> {
|
||||
try {
|
||||
const res = await promise
|
||||
return {
|
||||
data: res.data
|
||||
}
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const error = e as AxiosError
|
||||
return {
|
||||
error: error.code + " : " + String(error.response?.statusText)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
error: String(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async getLineInfo(iccid: string): Promise<Result<string, NosApi.LineData>> {
|
||||
const PATH = "/subscribers/" + iccid
|
||||
console.log("PAth", PATH)
|
||||
const lineRequest = this.httpClient.get<NosApi.LineDataResponseOK>(PATH)
|
||||
const lineResponse = await this.manageNosRequest<string, NosApi.LineDataResponseOK>(lineRequest)
|
||||
|
||||
if (lineResponse.error != undefined) {
|
||||
return lineResponse
|
||||
} else {
|
||||
return {
|
||||
data: lineResponse.data.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* El metodo de NOS de paginar las lineas
|
||||
* maximo por pagina 100, default 25
|
||||
* no devuelve el offset ni el numero de elementos restantes
|
||||
* hay que llevar la cuenta
|
||||
*/
|
||||
public async getLinePage(args: {
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
filter?: string,
|
||||
orderBy?: string
|
||||
}): Promise<Result<string, any>> {
|
||||
const PATH = "/subscribers"
|
||||
|
||||
const LIMIT = 100
|
||||
const options = {
|
||||
limit: args.limit ?? LIMIT,
|
||||
offset: args.offset ?? 0,
|
||||
filter: args.filter,
|
||||
orderBy: args.orderBy
|
||||
}
|
||||
|
||||
const pageRequest = this.httpClient.get(PATH, {
|
||||
params: options
|
||||
})
|
||||
|
||||
const pageResponse = await this.manageNosRequest<string, any>(pageRequest)
|
||||
if (pageResponse.error != undefined) {
|
||||
return pageResponse
|
||||
} else {
|
||||
return {
|
||||
data: pageResponse.data.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async getLinesInfo(iccid: string[]) /*Promise<Result<string, NosApi.LineData>>*/ {
|
||||
throw new Error("NOS no permite buscar iccid en bulk, se puede hacer un apaño pero está en proceso")
|
||||
const PATH = "/subscribers"
|
||||
const LIMIT = 100
|
||||
|
||||
const steps = Math.ceil(iccid.length / LIMIT)
|
||||
const options = {
|
||||
limit: LIMIT,
|
||||
offset: 0,
|
||||
}
|
||||
|
||||
const req = this.httpClient.post<NosApi.LineDataResponseOK>(PATH)
|
||||
const resp = await this.manageNosRequest<string, NosApi.LineDataResponseOK>(req)
|
||||
|
||||
if (resp.error != undefined) {
|
||||
return resp
|
||||
} else {
|
||||
return {
|
||||
//@ts-expect-error
|
||||
data: resp.data.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async activateSim(iccid: string): Promise<Result<string, NosApi.ActivationData>> {
|
||||
const PATH = '/provisioning'
|
||||
const PRODUCT_ID = 1330 // No se que es, preguntar a Ivan
|
||||
const data = {
|
||||
productSetId: PRODUCT_ID
|
||||
}
|
||||
|
||||
const req = this.httpClient.post<NosApi.ActivateResponseOK>(PATH, data)
|
||||
const resp = await this.manageNosRequest<string, NosApi.ActivateResponseOK>(req)
|
||||
|
||||
if (resp.error != undefined) {
|
||||
return resp
|
||||
} else {
|
||||
return {
|
||||
data: resp.data.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "A bar is a service provisioning action that results in a subscriber being blocked from accessing an operator's network. The bar remains in place until the operator is sent an unbar request."
|
||||
* Se entiende que un "bar" es una suspension temporal
|
||||
*/
|
||||
public async bar(iccid: string) {
|
||||
const PATH = `/subscribers/${iccid}/products`
|
||||
const data = {
|
||||
product: "BAR DN TOTAL",
|
||||
action: "enable"
|
||||
}
|
||||
|
||||
const req = this.httpClient.patch<NosApi.BarResponseOk>(PATH, data)
|
||||
const resp = await this.manageNosRequest<string, NosApi.BarResponseOk>(req)
|
||||
|
||||
if (resp.error != undefined) {
|
||||
return resp
|
||||
} else {
|
||||
return {
|
||||
data: resp.data.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async unbar(iccid: string) {
|
||||
const PATH = `/subscribers/${iccid}/products`
|
||||
const data = {
|
||||
product: "BAR DN TOTAL",
|
||||
action: "disable"
|
||||
}
|
||||
|
||||
const req = this.httpClient.patch<NosApi.BarResponseOk>(PATH, data)
|
||||
const resp = await this.manageNosRequest<string, NosApi.BarResponseOk>(req)
|
||||
|
||||
if (resp.error != undefined) {
|
||||
return resp
|
||||
} else {
|
||||
return {
|
||||
data: resp.data.content
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
{
|
||||
"name": "sim-consumidor-nos",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "consumidor generico de eventos de NOS",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "yarn tsc --project tsconfig.json && yarn tsc-alias && cp package.json ../../dist/packages/sim-consumidor-nos/",
|
||||
"esbuild": "esbuild index.ts --platform=node",
|
||||
"start": "node ../../dist/packages/sim-consumidor-nos/index.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "yarn@4.12.0",
|
||||
"imports": {
|
||||
"#config/*.js": {
|
||||
"types": "./config/*.ts",
|
||||
@@ -21,13 +12,13 @@
|
||||
"types": "./config/*.ts",
|
||||
"default": "./config/*.js"
|
||||
},
|
||||
"#adapters/*.js": {
|
||||
"types": "./adapters/*.ts",
|
||||
"default": "./adapters/*.js"
|
||||
"#infrastructure/*.js": {
|
||||
"types": "./infrastructure/*.ts",
|
||||
"default": "./infrastructure/*.js"
|
||||
},
|
||||
"#adapters/*": {
|
||||
"types": "./adapters/*.ts",
|
||||
"default": "./adapters/*.js"
|
||||
"#infrastructure/*": {
|
||||
"types": "./infrastructure/*.ts",
|
||||
"default": "./infrastructure/*.js"
|
||||
},
|
||||
"#domain/*.js": {
|
||||
"types": "./domain/*.ts",
|
||||
@@ -37,29 +28,32 @@
|
||||
"types": "./domain/*.ts",
|
||||
"default": "./domain/*.js"
|
||||
},
|
||||
"#ports/*.js": {
|
||||
"types": "./ports/*.ts",
|
||||
"default": "./ports/*.js"
|
||||
"#aplication/*.js": {
|
||||
"types": "./aplication/*.ts",
|
||||
"default": "./aplication/*.js"
|
||||
},
|
||||
"#ports/*": {
|
||||
"types": "./ports/*.ts",
|
||||
"default": "./ports/*.js"
|
||||
},
|
||||
"#tests/*.js": {
|
||||
"types": "./__tests__/*.ts",
|
||||
"default": "./__tests__/*.js"
|
||||
},
|
||||
"#tests/*": {
|
||||
"types": "./__tests__/*.ts",
|
||||
"default": "./__tests__/*.js"
|
||||
"#aplication/*": {
|
||||
"types": "./aplication/*.ts",
|
||||
"default": "./aplication/*.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "yarn tsc --project tsconfig.json && yarn tsc-alias && cp package.json ../../dist/packages/sim-consumidor-nos/",
|
||||
"esbuild": "esbuild index.ts --platform=node",
|
||||
"start": "node ../../dist/packages/sim-consumidor-nos/index.js",
|
||||
"dev": "tsx watch index.ts"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "yarn@4.12.0",
|
||||
"dependencies": {
|
||||
"@tsconfig/node22": "*",
|
||||
"amqplib": "^0.10.9",
|
||||
"cors": "*",
|
||||
"dotenv": "*",
|
||||
"express": "*",
|
||||
"sim-shared": "sim-shared:*",
|
||||
"typescript": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -70,6 +64,7 @@
|
||||
"@types/supertest": "*",
|
||||
"prettier": "*",
|
||||
"supertest": "*",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsx": "*",
|
||||
"vitest": "*"
|
||||
}
|
||||
|
||||
11
packages/sim-consumidor-nos/readme.md
Normal file
11
packages/sim-consumidor-nos/readme.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# NOS
|
||||
|
||||
## Particularidades de las operaciones de NOS
|
||||
|
||||
- Documentación de la API: [DOC](https://pelion-help.iot-x.com/nos/en/Content/API/APIReference/API%20Reference.htm?tocpath=_____7)
|
||||
- No se necesita la pre-activación de las SIM.
|
||||
- La suspensión y reactivación se llama "bar" y "unbar".
|
||||
- El token de Authentication dura exactamente 1 año, solo se puede refrescar
|
||||
desde la web.
|
||||
- En la documentación la URL de la API es <https://nos-api.iot-x.com> pero la
|
||||
de producción es <https://nosconnectcenter-api.iot-x.com>.
|
||||
5
packages/sim-consumidor-nos/test.env
Normal file
5
packages/sim-consumidor-nos/test.env
Normal file
@@ -0,0 +1,5 @@
|
||||
## ENV PARA DATOS DE TEST - shared nunca se lanza en produccion
|
||||
|
||||
NOTIFICATION_URL="https://sf-sim-activation.savefamilygps.net/send-activation-mail"
|
||||
# NOTIFICATION_URL="localhost"
|
||||
SIM_ACTIVATION_API_KEY=9e48c4ac-1ab0-4397-b3f3-6c239200dfe6
|
||||
@@ -2,16 +2,40 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist",
|
||||
"baseUrl": ".",
|
||||
"rootDir": "../../",
|
||||
"paths": {
|
||||
"#config/*": [
|
||||
"./config/*"
|
||||
],
|
||||
"#infrastructure/*": [
|
||||
"./infrastructure/*"
|
||||
],
|
||||
"#domain/*": [
|
||||
"./domain/*"
|
||||
],
|
||||
"#aplication/*": [
|
||||
"./aplication/*"
|
||||
],
|
||||
"config/*": [
|
||||
"./config/*"
|
||||
],
|
||||
"infrastructure/*": [
|
||||
"./infrastructure/*"
|
||||
],
|
||||
"domain/*": [
|
||||
"./domain/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"src/**/*.d.ts"
|
||||
"**/*.d.ts",
|
||||
"../../packages/sim-shared/**/*.ts"
|
||||
],
|
||||
"files": [
|
||||
"index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
# claves de Objenious
|
||||
HOST=0.0.0.0
|
||||
|
||||
OBJ_PEM_PATH=./obj.pem
|
||||
OBJ_AUTHORIZATION=XOc7FtwXD8hUX2SFVX94XSty8wkOmChkwDNF09O_aIxPubMDdFUdCDCB4zpzSIxi8nOcTg7r_LM_nmd5qm7uLbksf_XArjI8iAyhjKz_2BAXPhmvKs4Fc9f3vv5LDfCVrPB9lP8P7rJ66_qnWs4jvhLQxSfn29m96hgXeCf8oySdIDUjN2q9Js3KAS5LL52Ri6ryvUeO1PvMhaPQMWRqoHIqTV1wPfPtiqQwcjUPmu5GeW164Kq1JLgV3KaGzfCZ9Qv9lbv30EJrukXxWuLCAhBS0kzrBXZoWvf2pb9uh3Am_93_dDxiIGQfIap9ZU_m8ZD1HPgvZOMCY6ZkxQconQ
|
||||
OBJ_CLI_ASSERTION=XOc7FtwXD8hUX2SFVX94XSty8wkOmChkwDNF09O_aIxPubMDdFUdCDCB4zpzSIxi8nOcTg7r_LM_nmd5qm7uLbksf_XArjI8iAyhjKz_2BAXPhmvKs4Fc9f3vv5LDfCVrPB9lP8P7rJ66_qnWs4jvhLQxSfn29m96hgXeCf8oySdIDUjN2q9Js3KAS5LL52Ri6ryvUeO1PvMhaPQMWRqoHIqTV1wPfPtiqQwcjUPmu5GeW164Kq1JLgV3KaGzfCZ9Qv9lbv30EJrukXxWuLCAhBS0kzrBXZoWvf2pb9uh3Am_93_dDxiIGQfIap9ZU_m8ZD1HPgvZOMCY6ZkxQconQ
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, beforeEach, mock, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { SimController } from "./Sim.controller.js";
|
||||
import { EventBus } from "sim-shared/domain/EventBus.port.js";
|
||||
import { SimUseCases } from "./Sim.usecases.js";
|
||||
import { ConsumeMessage } from "amqplib";
|
||||
import { postgrClient, pgPool } from "#config/postgreConfig.js";
|
||||
import { httpInstance } from "#config/httpClient.config.js";
|
||||
import { OrderRepository } from "sim-shared/infrastructure/OrderRepository.js";
|
||||
import { PauseCancelTaskRepository } from "#adapters/PauseCancelTaskRepository.js";
|
||||
import { ObjeniousOperationsRepository } from "sim-shared/infrastructure/ObjeniousOperationRepository.js";
|
||||
import { ActionData } from "#domain/DTOs/objeniousapi.js";
|
||||
import { ObjeniousLinesRepository } from "sim-shared/infrastructure/ObjeniousLinesRepository.js";
|
||||
|
||||
describe("SimController Integration Tests (Real UseCases)", () => {
|
||||
let eventBusMock: any;
|
||||
let controller: SimController;
|
||||
let useCases: SimUseCases;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock ONLY the event bus as requested
|
||||
eventBusMock = {
|
||||
publish: mock.fn(),
|
||||
addSubscribers: mock.fn(),
|
||||
consume: mock.fn(),
|
||||
ack: mock.fn(async () => { }),
|
||||
nack: mock.fn(async () => { }),
|
||||
};
|
||||
|
||||
const operationRepository = new ObjeniousOperationsRepository(
|
||||
httpInstance,
|
||||
postgrClient,
|
||||
);
|
||||
const orderRepository = new OrderRepository(postgrClient);
|
||||
const pauseRepository = new PauseCancelTaskRepository(postgrClient);
|
||||
const linesRepository = new ObjeniousLinesRepository(postgrClient) // tiene que apuntar a "intranet"
|
||||
useCases = new SimUseCases({
|
||||
httpClient: httpInstance,
|
||||
operationRepository: operationRepository,
|
||||
orderRepository: orderRepository,
|
||||
pauseRepository: pauseRepository,
|
||||
objeniousLinesRepository: linesRepository
|
||||
});
|
||||
// @ts-expect-error
|
||||
useCases.findActivationDate = async (data: ActionData) => new Date()
|
||||
|
||||
controller = new SimController(eventBusMock as unknown as EventBus, useCases);
|
||||
});
|
||||
|
||||
const createMockMsg = (payload: any): ConsumeMessage => {
|
||||
return {
|
||||
content: Buffer.from(JSON.stringify(payload)),
|
||||
fields: {},
|
||||
properties: {
|
||||
headers: {
|
||||
message_id: "test-correlation-id"
|
||||
}
|
||||
},
|
||||
} as unknown as ConsumeMessage;
|
||||
};
|
||||
|
||||
after(async () => {
|
||||
await pgPool.end();
|
||||
});
|
||||
|
||||
describe("suspend", () => {
|
||||
it("should call stage_suspend and interact with DB and EventBus", async () => {
|
||||
const iccid = "test-iccid-suspend-" + Date.now();
|
||||
const msg = createMockMsg({
|
||||
key: "sim.test.pause",
|
||||
payload: {
|
||||
iccid: iccid
|
||||
},
|
||||
headers: {
|
||||
message_id: "correlation-suspend-" + iccid
|
||||
}
|
||||
});
|
||||
|
||||
const handler = controller.suspend();
|
||||
await handler(msg);
|
||||
|
||||
// Verify that it reached the stage_suspend logic (which adds to pauseRepository)
|
||||
// We can query the DB or check if ACK was called
|
||||
assert.strictEqual(eventBusMock.ack.mock.callCount(), 1, "Message should be ACKed on success");
|
||||
assert.strictEqual(eventBusMock.nack.mock.callCount(), 0, "Message should not be NACKed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminate", () => {
|
||||
it("should call stage_terminate and interact with DB and EventBus", async () => {
|
||||
const iccid = "test-iccid-terminate-" + Date.now();
|
||||
const msg = createMockMsg({
|
||||
key: "sim.test.pause",
|
||||
payload: {
|
||||
iccid: iccid
|
||||
},
|
||||
headers: {
|
||||
message_id: "correlation-terminate-" + iccid
|
||||
}
|
||||
});
|
||||
|
||||
const handler = controller.terminate();
|
||||
await handler(msg);
|
||||
|
||||
assert.strictEqual(eventBusMock.ack.mock.callCount(), 1, "Message should be ACKed on success");
|
||||
assert.strictEqual(eventBusMock.nack.mock.callCount(), 0, "Message should not be NACKed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should nack if message is invalid", async () => {
|
||||
const msg = {
|
||||
content: Buffer.from("invalid json"),
|
||||
fields: {},
|
||||
properties: {},
|
||||
} as unknown as ConsumeMessage;
|
||||
const handler = controller.suspend();
|
||||
await assert.rejects(handler(msg), "Error de suspension consumiendo el mensaje no es valido");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import { ConsumeMessage } from "amqplib";
|
||||
import { SimUseCases } from "./Sim.usecases.js";
|
||||
import { SimEvents } from "sim-shared/domain/SimEvents.js";
|
||||
import { Result } from "sim-shared/domain/Result.js";
|
||||
import { env } from "#config/env/index.js";
|
||||
import { ActionData } from "#domain/DTOs/objeniousapi.js";
|
||||
import { Request, Response } from "express"
|
||||
import { PaginationArgs, QueryPaginationArgs } from "sim-shared/domain/PaginationArgs.js";
|
||||
import { paginationValidator } from "./httpValidators.js";
|
||||
|
||||
/**
|
||||
* La clase usa generadores de funciones para mantener el contexto
|
||||
@@ -21,7 +24,6 @@ export class SimController {
|
||||
) {
|
||||
this.eventBus = eventBus
|
||||
this.useCases = useCases
|
||||
|
||||
}
|
||||
|
||||
private decodeMsg(msg: ConsumeMessage): object | undefined {
|
||||
@@ -37,6 +39,7 @@ export class SimController {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error al decodificar JSON:', error);
|
||||
console.error(Buffer.from(msg.content).toString(("utf8")))
|
||||
// Aquí podrías decidir devolver el string crudo o null
|
||||
return undefined;
|
||||
}
|
||||
@@ -86,7 +89,7 @@ export class SimController {
|
||||
const resp = await this.tryUseCase(msg, this.useCases.activate({
|
||||
correlation_id: msgData.headers?.message_id,
|
||||
dueDate: this.genDueDate(DUE_DATE_SECONDS).toISOString(),
|
||||
customerAccountCode: env.OBJ_CUSTOMER_CODE,
|
||||
customerAccountCode: "9.49411.10",
|
||||
identifier: {
|
||||
identifierType: "ICCID",
|
||||
identifiers: [iccid]
|
||||
@@ -108,7 +111,7 @@ export class SimController {
|
||||
return async (msg: ConsumeMessage) => {
|
||||
let msgData;
|
||||
try {
|
||||
msgData = this.validateMsg(msg) as SimEvents.pause
|
||||
msgData = this.validateMsg(msg) as SimEvents.suspend
|
||||
} catch (e) {
|
||||
throw new Error("Error de preactivacion consumiendo el mensaje no es valido" + String(e))
|
||||
}
|
||||
@@ -135,7 +138,7 @@ export class SimController {
|
||||
return async (msg: ConsumeMessage) => {
|
||||
let msgData;
|
||||
try {
|
||||
msgData = this.validateMsg(msg) as SimEvents.pause
|
||||
msgData = this.validateMsg(msg) as SimEvents.suspend
|
||||
} catch (e) {
|
||||
throw new Error("Error de reactivacion consumiendo el mensaje no es valido" + String(e))
|
||||
}
|
||||
@@ -157,11 +160,14 @@ export class SimController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lo mismo que pause
|
||||
*/
|
||||
public suspend() {
|
||||
return async (msg: ConsumeMessage) => {
|
||||
let msgData;
|
||||
try {
|
||||
msgData = this.validateMsg(msg) as SimEvents.pause
|
||||
msgData = this.validateMsg(msg) as SimEvents.suspend
|
||||
} catch (e) {
|
||||
throw new Error("Error de suspension consumiendo el mensaje no es valido" + String(e))
|
||||
}
|
||||
@@ -171,14 +177,18 @@ export class SimController {
|
||||
}
|
||||
|
||||
const iccid = msgData.payload.iccid
|
||||
const res = await this.tryUseCase(msg, this.useCases.suspend({
|
||||
const suspendData: ActionData = {
|
||||
correlation_id: msgData.headers?.message_id,
|
||||
dueDate: this.genDueDate(2 * 60).toISOString(),
|
||||
identifier: {
|
||||
identifierType: "ICCID",
|
||||
identifiers: [iccid]
|
||||
identifiers: [iccid] // Por algún motivo solo he puesto un iccd por identifier
|
||||
}
|
||||
}))
|
||||
}
|
||||
const useCaseRes = await this.tryUseCase(msg, this.useCases.stage_suspend(suspendData))
|
||||
/*
|
||||
const res = await this.tryUseCase(msg, this.useCases.suspend(actionData))
|
||||
*/
|
||||
|
||||
}
|
||||
}
|
||||
@@ -187,7 +197,7 @@ export class SimController {
|
||||
return async (msg: ConsumeMessage) => {
|
||||
let msgData;
|
||||
try {
|
||||
msgData = this.validateMsg(msg) as SimEvents.pause
|
||||
msgData = this.validateMsg(msg) as SimEvents.suspend
|
||||
} catch (e) {
|
||||
throw new Error("Error consumiendo el mensaje no es valido" + String(e))
|
||||
}
|
||||
@@ -195,16 +205,50 @@ export class SimController {
|
||||
if (msgData == undefined) {
|
||||
return Promise.reject("Mensaje invalido")
|
||||
}
|
||||
|
||||
const iccid = msgData.payload.iccid
|
||||
console.log("Mensaje procesado", msgData)
|
||||
const res = await this.tryUseCase(msg, this.useCases.terminate({
|
||||
const terminateActionData: ActionData = {
|
||||
correlation_id: msgData.headers?.message_id,
|
||||
dueDate: this.genDueDate(2 * 60).toISOString(),
|
||||
identifier: {
|
||||
identifierType: "ICCID",
|
||||
identifiers: [iccid]
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
//const res = await this.tryUseCase(msg, this.useCases.terminate(terminateActionData))
|
||||
const res = await this.tryUseCase(msg, this.useCases.stage_terminate(terminateActionData))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public queryLines() {
|
||||
const DEFAULT_LIMIT = 1000
|
||||
const DEFAULT_OFFSET = 0
|
||||
|
||||
return async (req: Request, res: Response) => {
|
||||
const queryParams = req.query
|
||||
|
||||
const paginationArgs: QueryPaginationArgs = {
|
||||
limit: queryParams.limit as string | undefined,
|
||||
offset: queryParams.offset as string | undefined
|
||||
}
|
||||
|
||||
const validationRes = paginationValidator.validate(paginationArgs)
|
||||
if (validationRes.error != undefined) {
|
||||
res.status(402).json(validationRes)
|
||||
return;
|
||||
}
|
||||
|
||||
const paginationValues = {
|
||||
limit: (queryParams.limit != undefined) ? Number(queryParams.limit) : DEFAULT_LIMIT,
|
||||
offset: (queryParams.offset != undefined) ? Number(queryParams.offset) : DEFAULT_OFFSET
|
||||
}
|
||||
|
||||
const status = req.query.status
|
||||
|
||||
const queryRes = await this.useCases.getLinesByQuery({ status: status as string | undefined }, paginationValues)
|
||||
res.json(queryRes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export class SimRouter {
|
||||
["activate", this.simController.activate()],
|
||||
["pause", this.simController.suspend()],
|
||||
["cancel", this.simController.terminate()],
|
||||
["reActivate", this.simController.reActivate()],
|
||||
["reactivate", this.simController.reActivate()],
|
||||
["preActivate", this.simController.preActivate()]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import { Result } from "sim-shared/domain/Result.js"
|
||||
import { ObjeniousOperation, IOperationsRepository as OperationsRepositoryPort } from "sim-shared/domain/operationsRepository.port.js"
|
||||
import assert from "node:assert"
|
||||
import { OrderRepository } from "sim-shared/infrastructure/OrderRepository.js"
|
||||
import { CreatePauseCancelTaskDTO, PauseCancelTaskRepository } from "#adapters/PauseCancelTaskRepository.js"
|
||||
import { ObjeniousOperationsRepository } from "sim-shared/infrastructure/ObjeniousOperationRepository.js"
|
||||
import { ObjeniousLinesRepository } from "sim-shared/infrastructure/ObjeniousLinesRepository.js"
|
||||
import { error } from "node:console"
|
||||
import { ObjeniousLine, ObjeniousLineDb } from "sim-shared/domain/objeniousLine.js"
|
||||
|
||||
// TODO:
|
||||
// - Pasar a un archivo de DTOs
|
||||
@@ -12,21 +17,26 @@ import { OrderRepository } from "sim-shared/infrastructure/OrderRepository.js"
|
||||
|
||||
export class SimUseCases {
|
||||
private readonly httpClient: HttpClient
|
||||
private readonly operationRepository: OperationsRepositoryPort
|
||||
private readonly objeniousRepository: ObjeniousOperationsRepository
|
||||
private readonly orderRepository: OrderRepository
|
||||
|
||||
private readonly pauseRepository: PauseCancelTaskRepository
|
||||
private readonly objeniousLinesRepository: ObjeniousLinesRepository
|
||||
constructor(args: {
|
||||
httpClient: HttpClient,
|
||||
operationRepository: OperationsRepositoryPort,
|
||||
orderRepository: OrderRepository
|
||||
operationRepository: ObjeniousOperationsRepository,
|
||||
orderRepository: OrderRepository,
|
||||
pauseRepository: PauseCancelTaskRepository,
|
||||
objeniousLinesRepository: ObjeniousLinesRepository
|
||||
}) {
|
||||
this.httpClient = args.httpClient
|
||||
this.operationRepository = args.operationRepository
|
||||
this.objeniousRepository = args.operationRepository
|
||||
this.orderRepository = args.orderRepository
|
||||
this.pauseRepository = args.pauseRepository
|
||||
this.objeniousLinesRepository = args.objeniousLinesRepository
|
||||
}
|
||||
|
||||
private async logOperation(data: ObjeniousOperation) {
|
||||
await this.operationRepository.createOperation({
|
||||
await this.objeniousRepository.createOperation({
|
||||
...data
|
||||
})
|
||||
}
|
||||
@@ -70,11 +80,14 @@ export class SimUseCases {
|
||||
operation: args.operation,
|
||||
iccids: String(args.iccid),
|
||||
status: "noMassID",
|
||||
request_id: response.data.requestId
|
||||
request_id: response.data.requestId,
|
||||
correlation_id: args.correlation_id
|
||||
}
|
||||
|
||||
// TODO: Esto tiene poco sentido si la operacion ya se
|
||||
// tenia que haber creado en el generador
|
||||
this.logOperation(operation)
|
||||
.then().catch(e => console.error(e))
|
||||
.then().catch(e => console.error("Error login operation", e))
|
||||
|
||||
if (args.correlation_id != undefined) {
|
||||
this.orderRepository.updateOrder({
|
||||
@@ -89,7 +102,6 @@ export class SimUseCases {
|
||||
error: undefined,
|
||||
data: true
|
||||
}
|
||||
|
||||
} else {
|
||||
return {
|
||||
error: String(response.status),
|
||||
@@ -109,8 +121,19 @@ export class SimUseCases {
|
||||
public activate(activationData: ActivationData): () => Promise<Result<string, boolean>> {
|
||||
const OPERATION_URL = "/actions/activateLine"
|
||||
return async () => {
|
||||
const iccid = activationData.identifier.identifiers
|
||||
// Comporbación excepcional para saber si la linea está suspendida
|
||||
const statusLinea = await this.objeniousRepository.getLinesAPI("ICCID", [String(iccid)])
|
||||
if (statusLinea.data != undefined && statusLinea.data[0].status.networkStatus == "SUSPENDED") {
|
||||
const res = await this.reActivate(activationData)()
|
||||
return res;
|
||||
}
|
||||
|
||||
const req = this.httpClient.client.post(OPERATION_URL, {
|
||||
...activationData
|
||||
dueDate: activationData.dueDate,
|
||||
identifier: activationData.identifier,
|
||||
customerAccountCode: activationData.customerAccountCode,
|
||||
offer: activationData.offer
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -189,16 +212,29 @@ export class SimUseCases {
|
||||
}
|
||||
}
|
||||
|
||||
public reActivate(pauseData: ActionData): () => Promise<Result<string, boolean>> {
|
||||
public reActivate(reactivateData: ActionData): () => Promise<Result<string, boolean>> {
|
||||
const OPERATION_URL = "/actions/reactivateLine"
|
||||
return async () => {
|
||||
const req = this.httpClient.client.post(OPERATION_URL, {
|
||||
...pauseData
|
||||
...reactivateData
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await req
|
||||
|
||||
// Creacion de la operacion inicial, antes de tener los datos
|
||||
const operation: ObjeniousOperation = {
|
||||
operation: "reactivate",
|
||||
iccids: reactivateData.identifier.identifiers[0],
|
||||
status: "noMassID",
|
||||
request_id: response.data.requestId,
|
||||
correlation_id: reactivateData.correlation_id
|
||||
}
|
||||
|
||||
// TODO: Esto tiene poco sentido si la operacion ya se
|
||||
// tenia que haber creado en el generador
|
||||
this.logOperation(operation)
|
||||
.then().catch(e => console.error("Error login operation", e))
|
||||
if (response.status == 200) {
|
||||
console.log("[o] Sim solicitud de reactivacion ", response.data)
|
||||
return <Result<string, boolean>>{
|
||||
@@ -214,7 +250,7 @@ export class SimUseCases {
|
||||
} catch (error) {
|
||||
console.error("[x] Error reactivacion", (error as AxiosError).response?.status)
|
||||
return <Result<string, boolean>>{
|
||||
error: "Error reactivando la sim" + pauseData.identifier,
|
||||
error: "Error reactivando la sim" + reactivateData.identifier,
|
||||
data: undefined
|
||||
}
|
||||
}
|
||||
@@ -225,23 +261,231 @@ export class SimUseCases {
|
||||
const OPERATION_URL = "/actions/suspendLine"
|
||||
return this.generateUseCase({
|
||||
correlation_id: suspendData.correlation_id,
|
||||
operationPayload: suspendData,
|
||||
operationPayload: {
|
||||
dueDate: suspendData.dueDate,
|
||||
identifier: suspendData.identifier
|
||||
},
|
||||
url: OPERATION_URL,
|
||||
iccid: suspendData.identifier.identifiers[0], //
|
||||
operation: "suspend"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Metodo muy especifico para obtener la fecha e activacion o en su defecto
|
||||
* la actual para saber cuando se va a completar el periodo de test de una linea
|
||||
*/
|
||||
private async findActivationDate(actionData: ActionData) {
|
||||
const iccid = actionData.identifier.identifiers
|
||||
const lineData = await this.objeniousRepository.getLinesAPI("ICCID", iccid)
|
||||
let activationDate = new Date()
|
||||
// Si no se pueden sacar datos de la linea guardo momentaneamente el error
|
||||
// pero no se cancela la operacion, el error puede ser de objenious y no nos
|
||||
// puede afectar
|
||||
//console.log("LineData", lineData.data)
|
||||
if (lineData.error != undefined) {
|
||||
console.error(lineData.error)
|
||||
} else {
|
||||
const activationDateStr = lineData.data[0].status.activationDate
|
||||
if (activationDateStr != undefined && activationDateStr != "") {
|
||||
activationDate = new Date(activationDateStr)
|
||||
}
|
||||
}
|
||||
return activationDate
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Paso previo a la suspension para evitar errores cuando el billing es test
|
||||
*/
|
||||
public stage_suspend(suspendData: ActionData): () => Promise<Result<string, boolean>> {
|
||||
return async (): Promise<Result<string, boolean>> => {
|
||||
const correlation_id = suspendData.correlation_id
|
||||
const iccid = suspendData.identifier.identifiers
|
||||
|
||||
|
||||
const operation: ObjeniousOperation = {
|
||||
operation: "suspend",
|
||||
iccids: iccid[0],
|
||||
status: "running",
|
||||
correlation_id: correlation_id
|
||||
}
|
||||
// No se registra hasta que no pase por la tabla de pausas
|
||||
// this.logOperation(operation)
|
||||
// .then().catch(e => console.error("Error login operation", e))
|
||||
|
||||
const fail = (error: string) => {
|
||||
console.error("[Sim.usecases]", error)
|
||||
if (correlation_id != undefined) {
|
||||
this.orderRepository.updateOrder({
|
||||
correlation_id: correlation_id,
|
||||
new_status: "failed"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TODO REGISTRAR EL ORDER
|
||||
/*
|
||||
if (correlation_id != undefined) {
|
||||
await this.orderRepository.createOrder({
|
||||
correlation_id: correlation_id,
|
||||
order_type: "pause"
|
||||
})
|
||||
}
|
||||
*/
|
||||
let activationDate;
|
||||
try {
|
||||
activationDate = await this.findActivationDate(suspendData)
|
||||
} catch (e) {
|
||||
return {
|
||||
error: String(e)
|
||||
}
|
||||
}
|
||||
const newTask: CreatePauseCancelTaskDTO = {
|
||||
iccid: iccid[0],
|
||||
activation_date: activationDate,
|
||||
next_check: undefined, // Que se haga instantaneamente al ser la primera
|
||||
operation_type: "suspend",
|
||||
action_data: suspendData
|
||||
}
|
||||
|
||||
const taskCreated = await this.pauseRepository.addTask(newTask)
|
||||
|
||||
// Caso que la task no se pueda crear en la BDD
|
||||
if (taskCreated.error != undefined) {
|
||||
fail(taskCreated.error)
|
||||
return {
|
||||
error: taskCreated.error
|
||||
}
|
||||
}
|
||||
|
||||
// Caso que se haya creado en la BDD
|
||||
if (correlation_id != undefined) {
|
||||
this.orderRepository.updateOrder({
|
||||
correlation_id: correlation_id,
|
||||
new_status: "running"
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
data: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paso previo a la suspension para evitar errores cuando el billing es test
|
||||
*/
|
||||
public stage_terminate(terminateData: ActionData): () => Promise<Result<string, boolean>> {
|
||||
return async (): Promise<Result<string, boolean>> => {
|
||||
const correlation_id = terminateData.correlation_id
|
||||
const iccid = terminateData.identifier.identifiers[0]
|
||||
|
||||
const activationDate = await this.findActivationDate(terminateData)
|
||||
const newTask: CreatePauseCancelTaskDTO = {
|
||||
iccid: iccid,
|
||||
activation_date: activationDate,
|
||||
next_check: undefined, // Que se haga instantaneamente al ser la primera
|
||||
operation_type: "terminate",
|
||||
action_data: terminateData
|
||||
}
|
||||
|
||||
const taskCreated = await this.pauseRepository.addTask(newTask)
|
||||
|
||||
const operation: ObjeniousOperation = {
|
||||
operation: "terminate",
|
||||
iccids: iccid,
|
||||
status: "running",
|
||||
correlation_id: correlation_id
|
||||
}
|
||||
|
||||
/**
|
||||
this.logOperation(operation)
|
||||
.then().catch(e => console.error("Error login operation", e))
|
||||
*/
|
||||
// Caso que la task no se pueda crear en la BDD
|
||||
if (taskCreated.error != undefined) {
|
||||
console.error("[Sim.usecases]", taskCreated.error)
|
||||
if (correlation_id != undefined) {
|
||||
this.orderRepository.updateOrder({
|
||||
correlation_id: correlation_id,
|
||||
new_status: "failed"
|
||||
})
|
||||
}
|
||||
return {
|
||||
error: taskCreated.error
|
||||
}
|
||||
}
|
||||
|
||||
// Caso que se haya creado en la BDD
|
||||
if (correlation_id != undefined) {
|
||||
this.orderRepository.updateOrder({
|
||||
correlation_id: correlation_id,
|
||||
new_status: "running"
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
data: true
|
||||
}
|
||||
}
|
||||
}
|
||||
public terminate(terminationData: ActionData): () => Promise<Result<string, boolean>> {
|
||||
const OPERATION_URL = "/actions/terminateLine"
|
||||
return this.generateUseCase({
|
||||
correlation_id: terminationData.correlation_id,
|
||||
operationPayload: terminationData,
|
||||
operationPayload: {
|
||||
dueDate: terminationData.dueDate,
|
||||
identifier: terminationData.identifier
|
||||
},
|
||||
url: OPERATION_URL,
|
||||
iccid: terminationData.identifier.identifiers[0], //
|
||||
iccid: terminationData.identifier.identifiers[0],
|
||||
operation: "terminate"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el tiempo que una linea ha estado en suspensión
|
||||
*/
|
||||
public async getSuspendedTime(iccid: string):
|
||||
Promise<Result<string, { total_milliseconds: number, total_days: number }>> {
|
||||
try {
|
||||
const result = await this.objeniousRepository.getSuspendedTime(iccid);
|
||||
if (result.error !== undefined) {
|
||||
return { error: result.error as string, data: undefined };
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
total_milliseconds: result.data!.total_milliseconds,
|
||||
total_days: result.data!.total_days
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Sim.usecases] Error getting suspended time", error);
|
||||
return { error: "Error getting suspended time", data: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busqueda de líneas **en nuestro volcado** según una query y con paginacion
|
||||
*/
|
||||
public async getLinesByQuery(query: { status?: string | undefined }, pagination: { limit: number, offset: number })
|
||||
: Promise<Result<string, {
|
||||
data: ObjeniousLineDb[],
|
||||
offset: number,
|
||||
rowCount: number
|
||||
}>> {
|
||||
try {
|
||||
|
||||
const linesQuery = await this.objeniousLinesRepository.getLinesByStatus(query, pagination)
|
||||
return {
|
||||
data: linesQuery,
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
error: String(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BodyValidator, Validator } from "sim-shared/aplication/BodyValidator.js";
|
||||
import { QueryPaginationArgs } from "sim-shared/domain/PaginationArgs.js";
|
||||
|
||||
const limitPositiveOrUndefined = <Validator<QueryPaginationArgs>>{
|
||||
field: "limit",
|
||||
validationFunc: (args) => (args.limit == undefined || !isNaN(+args.limit) && parseInt(args.limit) >= 0),
|
||||
errorMsg: "El campo limit debe ser un numero o undefined (default 0)"
|
||||
}
|
||||
const offsetPositiveOrUndefined = <Validator<QueryPaginationArgs>>{
|
||||
field: "offset",
|
||||
validationFunc: (args) => (args.offset == undefined || isNaN(+args.offset) && parseInt(args.offset) >= 1),
|
||||
errorMsg: "El campo offset debe ser un numero o undefined (default 0)"
|
||||
}
|
||||
|
||||
export const paginationValidator = new BodyValidator<QueryPaginationArgs & {}>([
|
||||
limitPositiveOrUndefined,
|
||||
offsetPositiveOrUndefined
|
||||
])
|
||||
@@ -4,7 +4,10 @@ import path from "node:path";
|
||||
loadEnvFile(path.join("../../.env")) // Global
|
||||
loadEnvFile(path.join("./.env")) // base
|
||||
|
||||
|
||||
export const env = {
|
||||
PORT: parseInt(process.env.OBJENIOUS_CONSUMER_PORT || "3002"),
|
||||
|
||||
ENVIRONMENT: process.env.ENVIORMENT,
|
||||
POSTGRES_USER: process.env.POSTGRES_USER,
|
||||
POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD,
|
||||
|
||||
@@ -18,24 +18,26 @@ export const rmqConnOptions = <RMQConnectionParams>{
|
||||
secure: rmqSecure,
|
||||
}
|
||||
|
||||
export const QUEUES = {
|
||||
OBJ: "sim.objenious",
|
||||
OBJDLX: "sim.objenious.dlx",
|
||||
OBJDEL: "sim.objenious.delayed",
|
||||
}
|
||||
|
||||
export const EXCHANGES = {
|
||||
MAIN: "sim.exchange",
|
||||
DLX: "sim.ex.objenious.dlx",
|
||||
DEL: "sim.ex.objenious.delayed"
|
||||
}
|
||||
export const rabbitmqEventBus = new RabbitMQEventBus({
|
||||
connectionParams: rmqConnOptions,
|
||||
buildStructure: buildQueues,
|
||||
maxRetry: 5
|
||||
maxRetry: 5,
|
||||
delayedExchange: EXCHANGES.DEL,
|
||||
dlxExchange: EXCHANGES.DLX
|
||||
})
|
||||
|
||||
async function buildQueues(channel: Channel) {
|
||||
const QUEUES = {
|
||||
OBJ: "sim.objenious",
|
||||
DLX: "sim.objenious.dlx",
|
||||
DEL: "sim.objenious.delayed"
|
||||
}
|
||||
|
||||
const EXCHANGES = {
|
||||
MAIN: "sim.exchange",
|
||||
DLX: "sim.ex.objenious.dlx",
|
||||
DEL: "sim.ex.objenious.delayed"
|
||||
}
|
||||
|
||||
const DELAY = 10 * 1000
|
||||
const BASE_OBENIOUS_KEY = "sim.objenious.#"
|
||||
@@ -45,8 +47,8 @@ async function buildQueues(channel: Channel) {
|
||||
await channel.assertExchange(EXCHANGES.MAIN, "topic")
|
||||
|
||||
await channel.assertQueue(QUEUES.OBJ)
|
||||
await channel.assertQueue(QUEUES.DLX)
|
||||
await channel.assertQueue(QUEUES.DEL, {
|
||||
await channel.assertQueue(QUEUES.OBJDLX)
|
||||
await channel.assertQueue(QUEUES.OBJDEL, {
|
||||
durable: true,
|
||||
arguments: {
|
||||
'x-message-ttl': DELAY,
|
||||
@@ -55,9 +57,9 @@ async function buildQueues(channel: Channel) {
|
||||
})
|
||||
|
||||
// Cola dead-letter
|
||||
await channel.bindQueue(QUEUES.DLX, EXCHANGES.DLX, "sim.objenious.#")
|
||||
await channel.bindQueue(QUEUES.OBJDLX, EXCHANGES.DLX, "sim.objenious.#")
|
||||
// Cola delay
|
||||
await channel.bindQueue(QUEUES.DEL, EXCHANGES.DEL, BASE_OBENIOUS_KEY)
|
||||
await channel.bindQueue(QUEUES.OBJDEL, EXCHANGES.DEL, BASE_OBENIOUS_KEY)
|
||||
// Cola objenious -> main exchange
|
||||
await channel.bindQueue(QUEUES.OBJ, EXCHANGES.MAIN, BASE_OBENIOUS_KEY)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpClient } from "sim-shared/infrastructure/HTTPClient.js"
|
||||
import { JWTService } from "../aplication/JWT.service.js"
|
||||
import { env } from "./env/index.js"
|
||||
import { jwtService } from "./jwtService.config.js"
|
||||
|
||||
const OBJ_BASE_URL = env.OBJ_BASE_URL
|
||||
|
||||
@@ -9,5 +9,5 @@ export const httpInstance = new HttpClient({
|
||||
headers: {
|
||||
"content-type": " application/json; charset=utf-8"
|
||||
},
|
||||
jwtManager: new JWTService()
|
||||
jwtManager: jwtService
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Cliente de postgres para la intranet. Se usa solo porque hace falta para el
|
||||
* volcado de datos, si se usa en mas partes algo estás haciendo mal.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { PgClient } from 'sim-shared/infrastructure/PgClient.js'
|
||||
import { env } from './env/index.js';
|
||||
|
||||
export const pgPoolIntranet = new Pool({
|
||||
user: env.POSTGRES_USER,
|
||||
host: env.POSTGRES_HOST,
|
||||
database: "intranet",
|
||||
password: env.POSTGRES_PASSWORD,
|
||||
port: Number(env.POSTGRES_PORT) || 5432,
|
||||
});
|
||||
|
||||
export const postgresClientIntranet = new PgClient({
|
||||
pool: pgPoolIntranet
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { GrantAccessRequestBody, JWTService } from "sim-shared/aplication/JWT.service.js"
|
||||
import { env } from "./env/index.js"
|
||||
import { JWTHeader } from "sim-shared/domain/JWT.js"
|
||||
|
||||
|
||||
const PRIVATE_KEY_PATH = env.OBJ_PEM_PATH
|
||||
|
||||
const GET_TOKEN_URL = "https://idp.docapost.io/auth/realms/GETWAY/protocol/openid-connect/token"
|
||||
const REFRESH_TOKEN_URL = GET_TOKEN_URL
|
||||
|
||||
const DEFAULT_BODY: GrantAccessRequestBody = {
|
||||
grant_type: "client_credentials",
|
||||
client_id: env.OBJ_CLIENT_ID,
|
||||
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
client_assertion: env.OBJ_CLI_ASSERTION
|
||||
}
|
||||
|
||||
|
||||
const DEFAULT_HEADERS = {
|
||||
"content-type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
const DEFAULT_HEADERS_JWT = {
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
kid: env.OBJ_KID,
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_JWT = {
|
||||
sub: env.OBJ_CLIENT_ID,
|
||||
iss: env.OBJ_CLIENT_ID,
|
||||
aud: "https://idp.docapost.io/auth/realms/GETWAY",
|
||||
jti: Date.now().toString(),
|
||||
|
||||
}
|
||||
|
||||
function addIATHeaders(authHeaders: Object) {
|
||||
const headers = <JWTHeader>{
|
||||
...authHeaders,
|
||||
sub: env.OBJ_CLIENT_ID,
|
||||
iss: env.OBJ_CLIENT_ID,
|
||||
aud: GET_TOKEN_URL,
|
||||
jti: Date.now().toString(),
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 5 * 60,
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export const jwtService = new JWTService({
|
||||
transformJWTHeaders: addIATHeaders,
|
||||
defaultHeaders: DEFAULT_HEADERS,
|
||||
defaultBody: DEFAULT_BODY,
|
||||
defaultJWTHeaders: DEFAULT_HEADERS_JWT,
|
||||
defaultJWTPayload: DEFAULT_DATA_JWT,
|
||||
privateKeyPath: PRIVATE_KEY_PATH,
|
||||
tokenUrl: GET_TOKEN_URL,
|
||||
refreshTokenUrl: REFRESH_TOKEN_URL
|
||||
})
|
||||
@@ -8,6 +8,14 @@ import { SimUseCases } from "./aplication/Sim.usecases.js"
|
||||
import { SimController } from "./aplication/Sim.controller.js"
|
||||
import { SimRouter } from "./aplication/Sim.router.js"
|
||||
import { OrderRepository } from "sim-shared/infrastructure/OrderRepository.js"
|
||||
import { PauseCancelTaskRepository } from "#adapters/PauseCancelTaskRepository.js"
|
||||
|
||||
import express from "express"
|
||||
import cors from "cors"
|
||||
import assert from "node:assert";
|
||||
import { env } from "#config/env/index.js"
|
||||
import { ObjeniousLinesRepository } from "sim-shared/infrastructure/ObjeniousLinesRepository.js"
|
||||
import { postgresClientIntranet } from "#config/intranetPostgresConfig.js"
|
||||
|
||||
async function startWorker() {
|
||||
const rmqClient = await startRMQClient()
|
||||
@@ -18,21 +26,83 @@ async function startWorker() {
|
||||
|
||||
await pgClient.checkDatabaseConnection()
|
||||
|
||||
const operationRepository = new ObjeniousOperationsRepository(pgClient)
|
||||
const operationRepository = new ObjeniousOperationsRepository(
|
||||
httpClient,
|
||||
pgClient,
|
||||
)
|
||||
const orderRepository = new OrderRepository(pgClient)
|
||||
|
||||
const simActivationController = new SimController(
|
||||
rmqClient,
|
||||
new SimUseCases({
|
||||
httpClient: httpClient,
|
||||
operationRepository: operationRepository,
|
||||
orderRepository: orderRepository
|
||||
})
|
||||
const pauseRepository = new PauseCancelTaskRepository(pgClient)
|
||||
const linesRepository = new ObjeniousLinesRepository(
|
||||
postgresClientIntranet
|
||||
)
|
||||
const simRouter = new SimRouter(simActivationController, rmqClient)
|
||||
|
||||
const simUseCases = new SimUseCases({
|
||||
httpClient: httpClient,
|
||||
operationRepository: operationRepository,
|
||||
orderRepository: orderRepository,
|
||||
pauseRepository: pauseRepository,
|
||||
objeniousLinesRepository: linesRepository
|
||||
})
|
||||
|
||||
const simController = new SimController(
|
||||
rmqClient,
|
||||
simUseCases
|
||||
)
|
||||
const simRouter = new SimRouter(simController, rmqClient)
|
||||
|
||||
// de momento solo una cola por simplificar
|
||||
rmqClient.consume("sim.objenious", simRouter.route)
|
||||
|
||||
// Servidor express
|
||||
const port = env.PORT
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
app.get("/health", async (req, res) => {
|
||||
res.json({ ok: "true" })
|
||||
})
|
||||
|
||||
// TODO: meter el template de controller con los validadores
|
||||
app.get("/lines/:iccid/suspended-time", async (req, res) => {
|
||||
const iccid = req.params.iccid
|
||||
if (!iccid) {
|
||||
res.status(400).json({ error: "iccid is required" })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await simUseCases.getSuspendedTime(iccid)
|
||||
if (result.error !== undefined) {
|
||||
res.status(500).json({ error: result.error })
|
||||
return
|
||||
}
|
||||
|
||||
res.json(result) // {data:{...}} || {error:{...}}
|
||||
})
|
||||
|
||||
/**
|
||||
* Opciones query:
|
||||
* - state
|
||||
*
|
||||
* Respuestas:
|
||||
* - OK: data: {
|
||||
* lines: ObjeniousLineDb[],
|
||||
* offset: number,
|
||||
* rowCount: number
|
||||
* }
|
||||
*
|
||||
* - ERR: error: {
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
app.get("/lines", simController.queryLines())
|
||||
|
||||
|
||||
assert.ok(port, "Puerto del servicio no definido")
|
||||
app.listen(port, () => {
|
||||
console.log(`[o] HTTP server listening on port ${port}`)
|
||||
})
|
||||
}
|
||||
|
||||
startWorker()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { after, before, describe, it } from "node:test";
|
||||
import { CreatePauseCancelTaskDTO, PauseCancelTaskRepository } from "./PauseCancelTaskRepository.js";
|
||||
import { postgrClient } from "#config/postgreConfig.js";
|
||||
import assert from "node:assert";
|
||||
|
||||
const testTask: CreatePauseCancelTaskDTO = {
|
||||
iccid: "1234",
|
||||
operation_type: "suspend",
|
||||
activation_date: new Date(),
|
||||
next_check: new Date(),
|
||||
action_data: {
|
||||
dueDate: new Date().toString(),
|
||||
correlation_id: "12223",
|
||||
identifier: {
|
||||
identifiers: ["1234"],
|
||||
identifierType: "ICCID"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("Test PauseCancelTaskRepository - DB", () => {
|
||||
|
||||
const createdIds: number[] = [];
|
||||
const pauseRepo = new PauseCancelTaskRepository(postgrClient)
|
||||
|
||||
before(() => {
|
||||
})
|
||||
|
||||
after(() => {
|
||||
})
|
||||
|
||||
it("Should create a task", async () => {
|
||||
const created = await pauseRepo.addTask(testTask)
|
||||
assert.ok(created != undefined, "A value must be returned always")
|
||||
assert.ok(created.error == undefined, "Should not return a error")
|
||||
assert.ok(created.data != undefined, "Data must be returned")
|
||||
createdIds.push(created.data.id)
|
||||
})
|
||||
|
||||
it("Should update a existing task", async () => {
|
||||
const updated = await pauseRepo.updateTask({
|
||||
id: createdIds[0],
|
||||
next_check: new Date()
|
||||
})
|
||||
|
||||
assert.ok(updated != undefined, "A value must be returned always")
|
||||
assert.ok(updated.error == undefined, "Should not return a error")
|
||||
assert.ok(updated.data != undefined, "Data must be returned")
|
||||
})
|
||||
|
||||
it("Should finish a existing task", async () => {
|
||||
const finish = await pauseRepo.finishTask({
|
||||
id: createdIds[0],
|
||||
error: "ok"
|
||||
})
|
||||
|
||||
assert.ok(finish != undefined, "A value must be returned always")
|
||||
assert.ok(finish.error == undefined, "Should not return a error")
|
||||
assert.ok(finish.data != undefined, "Data must be returned")
|
||||
})
|
||||
|
||||
it("Should get at least 1 pending task", async () => {
|
||||
const created = await pauseRepo.addTask(testTask)
|
||||
const pending = await pauseRepo.getPending()
|
||||
|
||||
assert.ok(pending != undefined, "A value must be returned always")
|
||||
assert.ok(pending.error == undefined, "Should not return a error")
|
||||
assert.ok(pending.data != undefined, "Data must be returned")
|
||||
|
||||
console.log("--> ", pending.data[0])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Result } from "sim-shared/domain/Result.js";
|
||||
import { QueryResult } from "pg";
|
||||
import { PgClient } from "sim-shared/infrastructure/PgClient.js";
|
||||
import { AxiosError } from "axios";
|
||||
import { ActionData } from "#domain/DTOs/objeniousapi.js";
|
||||
|
||||
export type PauseCancelTask = {
|
||||
id: number;
|
||||
iccid: string;
|
||||
operation_type: "suspend" | "terminate",
|
||||
last_checked?: Date | null;
|
||||
activation_date?: Date | null;
|
||||
next_check?: Date | null;
|
||||
completed_date?: Date | null;
|
||||
error?: string | null;
|
||||
action_data: ActionData
|
||||
}
|
||||
|
||||
export type CreatePauseCancelTaskDTO = Pick<PauseCancelTask, "iccid" | "activation_date" | "next_check" | "operation_type" | "action_data">
|
||||
export type UpdatePauseCancelTaskDTO = Pick<PauseCancelTask, "id" | "next_check">
|
||||
export type FinishPauseCancelTaskDTO = Pick<PauseCancelTask, "id" | "error">
|
||||
|
||||
/**
|
||||
* Repositorio para compensar los problemas de cacelcaiones/pausas de objenious a
|
||||
* la hora aplicarlo sobre una linea con el billing a test.
|
||||
*/
|
||||
export class PauseCancelTaskRepository {
|
||||
constructor(
|
||||
private readonly pgClient: PgClient
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene las siguientes que se pueden lanzar, puede haber más pero
|
||||
* estan pendientes
|
||||
*/
|
||||
public async getPending(): Promise<Result<string, PauseCancelTask[]>> {
|
||||
const sql = `
|
||||
SELECT * FROM pause_cancel_tasks
|
||||
WHERE completed_date IS NULL
|
||||
AND (next_check <= NOW() OR next_check IS NULL)
|
||||
ORDER BY id ASC;
|
||||
`;
|
||||
|
||||
try {
|
||||
const res: QueryResult<PauseCancelTask> = await this.pgClient.query(sql);
|
||||
return {
|
||||
data: res.rows
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
error: (e as AxiosError).message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async addTask(task: CreatePauseCancelTaskDTO): Promise<Result<string, PauseCancelTask>> {
|
||||
|
||||
const sql = `
|
||||
INSERT INTO pause_cancel_tasks (iccid, activation_date, next_check, last_checked, operation_type, action_data)
|
||||
VALUES ($1, $2, $3, now(), $4, $5)
|
||||
RETURNING *;
|
||||
`;
|
||||
try {
|
||||
const values = [task.iccid, task.activation_date, task.next_check, task.operation_type, JSON.stringify(task.action_data)];
|
||||
const res: QueryResult<PauseCancelTask> = await this.pgClient.query(sql, values);
|
||||
return {
|
||||
data: res.rows[0]
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
error: (e as AxiosError).message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Se ha vuelto a comprobar la tarea pero sigue en test
|
||||
*/
|
||||
public async updateTask(updateData: UpdatePauseCancelTaskDTO): Promise<Result<string, PauseCancelTask>> {
|
||||
|
||||
const sql = `
|
||||
UPDATE pause_cancel_tasks
|
||||
SET last_checked = now(), next_check = $1
|
||||
WHERE id = $2
|
||||
RETURNING *;
|
||||
`;
|
||||
try {
|
||||
const res = await this.pgClient.query<PauseCancelTask>(sql, [updateData.next_check, updateData.id]);
|
||||
return {
|
||||
data: res.rows[0]
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
error: (e as AxiosError).message
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* La tarea ha termiando bien o mal
|
||||
*/
|
||||
public async finishTask(finishData: FinishPauseCancelTaskDTO) {
|
||||
const sql = `
|
||||
UPDATE pause_cancel_tasks
|
||||
SET completed_date = NOW(), error = $1
|
||||
WHERE id = $2
|
||||
RETURNING *;
|
||||
`;
|
||||
|
||||
try {
|
||||
const res = await this.pgClient.query(sql, [finishData.error, finishData.id]);
|
||||
return {
|
||||
data: res.rows[0]
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
error: (e as AxiosError).message
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default PauseCancelTask
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user