-
Notifications
You must be signed in to change notification settings - Fork 0
Add Event Sourcing & Transactional Outbox guidance; expand anti-patterns and migration playbook #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| # Content Gap Audit (Design-Patterns Repo) | ||
|
|
||
| ## Critical | ||
| - Added missing **Transactional Outbox** system pattern and **Dual Writes** anti-pattern coverage. | ||
| - Added migration path **Direct Publish -> Transactional Outbox** to reduce data/event inconsistency risk. | ||
|
|
||
| ## High | ||
| - Added **Event Sourcing** as a common system pattern not previously covered. | ||
| - Added **N+1 Query Problem** anti-pattern because it is a high-frequency production issue. | ||
|
|
||
| ## Medium | ||
| - Added **Version Baseline** section in framework catalog to make version intent explicit and reviewable. | ||
| - Verified all references listed in `skills/design-patterns/SKILL.md` exist in repository. | ||
|
|
||
| ## Low | ||
| - Ran a markdown link/path scan (no actionable broken local links detected). | ||
| - Minor wording cleanup happened indirectly via added sections. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -99,15 +99,20 @@ export class UserRepository { | |||||||||||
| ```python | ||||||||||||
| # routers/user.py — Controller | ||||||||||||
| from fastapi import APIRouter, Depends | ||||||||||||
| from schemas.user import CreateUserDTO, User | ||||||||||||
| from services.user_service import UserService | ||||||||||||
|
|
||||||||||||
| router = APIRouter() | ||||||||||||
|
|
||||||||||||
| @router.post("/users", status_code=201) | ||||||||||||
| @router.post("/users", status_code=201, response_model=User) | ||||||||||||
| async def create_user(data: CreateUserDTO, service: UserService = Depends()): | ||||||||||||
| return await service.create(data) | ||||||||||||
|
|
||||||||||||
| # services/user_service.py — Business Logic | ||||||||||||
| from fastapi import Depends, HTTPException | ||||||||||||
| from repositories.user_repository import UserRepository | ||||||||||||
| from utils.security import hash_password | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+112
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing type imports in the service snippet.
Proposed doc fix from fastapi import Depends, HTTPException
+from schemas.user import CreateUserDTO, User
from repositories.user_repository import UserRepository
from utils.security import hash_password🤖 Prompt for AI Agents |
||||||||||||
| class UserService: | ||||||||||||
| def __init__(self, repo: UserRepository = Depends()): | ||||||||||||
| self.repo = repo | ||||||||||||
|
|
@@ -117,12 +122,17 @@ class UserService: | |||||||||||
| if existing: | ||||||||||||
| raise HTTPException(409, "Email already exists") | ||||||||||||
| hashed = hash_password(data.password) | ||||||||||||
| return await self.repo.save({**data.dict(), "password": hashed}) | ||||||||||||
| return await self.repo.save({**data.model_dump(), "password": hashed}) | ||||||||||||
|
|
||||||||||||
| # repositories/user_repository.py — Data Access | ||||||||||||
| from db.client import database | ||||||||||||
|
|
||||||||||||
| class UserRepository: | ||||||||||||
| async def find_by_email(self, email: str) -> User | None: | ||||||||||||
| return await database.users.find_one({"email": email}) | ||||||||||||
|
|
||||||||||||
| async def save(self, data: dict) -> User: | ||||||||||||
| return await database.users.insert_one(data) | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎.
Comment on lines
+134
to
+135
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result:
From that result you typically use:
Example: result = collection.insert_one({"x": 1})
_id = result.inserted_id
``` [2]
**Sources:** [1] PyMongo `InsertOneResult` API docs, [2] PyMongo `Collection.insert_one` API docs
---
🌐 Web query:
`Motor async MongoDB driver insert_one return type`
💡 Result:
In Motor (e.g., `AsyncIOMotorCollection.insert_one()`), `insert_one` is an async coroutine, so you do:
```py
result = await collection.insert_one(doc)and it returns a Source(s): [1] (motor.readthedocs.io) Citations:
Fix the return type contract: The code declares Proposed doc fix class UserRepository:
async def save(self, data: dict) -> User:
- return await database.users.insert_one(data)
+ result = await database.users.insert_one(data)
+ return await database.users.find_one({"_id": result.inserted_id})📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
| ``` | ||||||||||||
|
|
||||||||||||
| --- | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -351,6 +351,69 @@ Week 4: Remove old code from monolith | |||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| **Step 6: Repeat** for next least-coupled module. | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| ## Migration 6: Direct Publish → Transactional Outbox | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| **Difficulty:** ⭐⭐⭐⭐ (Advanced) | ||||||||||||||||||||||||||||||||||||||
| **Time:** 2-5 days | ||||||||||||||||||||||||||||||||||||||
| **Risk:** High (touches write path + async workers) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| ### Problem Signature | ||||||||||||||||||||||||||||||||||||||
| ```typescript | ||||||||||||||||||||||||||||||||||||||
| await orderRepo.save(order); // DB write | ||||||||||||||||||||||||||||||||||||||
| await eventBus.publish(new OrderCreated); // separate write (can fail) | ||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| ### Steps | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| **Step 1: Add outbox table** | ||||||||||||||||||||||||||||||||||||||
| ```sql | ||||||||||||||||||||||||||||||||||||||
| CREATE TABLE outbox_events ( | ||||||||||||||||||||||||||||||||||||||
| id UUID PRIMARY KEY, | ||||||||||||||||||||||||||||||||||||||
| aggregate_type TEXT NOT NULL, | ||||||||||||||||||||||||||||||||||||||
| aggregate_id TEXT NOT NULL, | ||||||||||||||||||||||||||||||||||||||
| event_type TEXT NOT NULL, | ||||||||||||||||||||||||||||||||||||||
| payload JSONB NOT NULL, | ||||||||||||||||||||||||||||||||||||||
| status TEXT NOT NULL DEFAULT 'pending', | ||||||||||||||||||||||||||||||||||||||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||||||||||||||||||||||||||||||||||||||
| sent_at TIMESTAMPTZ | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| **Step 2: Write business row + outbox row in one transaction** | ||||||||||||||||||||||||||||||||||||||
| ```typescript | ||||||||||||||||||||||||||||||||||||||
| await prisma.$transaction(async (tx) => { | ||||||||||||||||||||||||||||||||||||||
| await tx.order.create({ data: orderData }); | ||||||||||||||||||||||||||||||||||||||
| await tx.outboxEvent.create({ | ||||||||||||||||||||||||||||||||||||||
| data: { | ||||||||||||||||||||||||||||||||||||||
| aggregateType: 'order', | ||||||||||||||||||||||||||||||||||||||
| aggregateId: orderData.id, | ||||||||||||||||||||||||||||||||||||||
| eventType: 'order.created', | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+387
to
+393
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use persisted order ID, not
Suggested fix await prisma.$transaction(async (tx) => {
- await tx.order.create({ data: orderData });
+ const createdOrder = await tx.order.create({ data: orderData });
await tx.outboxEvent.create({
data: {
aggregateType: 'order',
- aggregateId: orderData.id,
+ aggregateId: createdOrder.id,
eventType: 'order.created',
- payload: orderData,
+ payload: createdOrder,
},
});
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| payload: orderData, | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| **Step 3: Add outbox publisher worker** | ||||||||||||||||||||||||||||||||||||||
| - Poll pending rows in small batches | ||||||||||||||||||||||||||||||||||||||
| - Publish to broker | ||||||||||||||||||||||||||||||||||||||
| - Mark rows as sent | ||||||||||||||||||||||||||||||||||||||
| - Retry with exponential backoff | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| **Step 4: Make consumers idempotent** | ||||||||||||||||||||||||||||||||||||||
| - Deduplicate by event ID | ||||||||||||||||||||||||||||||||||||||
| - Store processed event IDs | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| **Step 5: Verify** | ||||||||||||||||||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||||||||||||||||||
| # Simulate broker outage, ensure events remain in outbox pending state | ||||||||||||||||||||||||||||||||||||||
| npm test | ||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| ## Migration Verification Checklist | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -100,6 +100,7 @@ describe("EventBus", () => { | |
| describe("CommandHistory", () => { | ||
| let history: CommandHistory; | ||
| let cart: Cart; | ||
| const laptop: CartItem = { id: "1", name: "Laptop", qty: 1 }; | ||
|
|
||
| beforeEach(() => { | ||
| history = new CommandHistory(); | ||
|
|
@@ -192,9 +193,11 @@ describe("Middleware Decorators", () => { | |
| **The Key:** Mock the Repository, test the Service logic. | ||
|
|
||
| ```typescript | ||
| import type { Mocked } from "vitest"; | ||
|
|
||
| describe("UserService", () => { | ||
| let service: UserService; | ||
| let mockRepo: jest.Mocked<UserRepository>; | ||
| let mockRepo: Mocked<UserRepository>; | ||
|
|
||
| beforeEach(() => { | ||
| mockRepo = { | ||
|
|
@@ -232,6 +235,7 @@ describe("UserRepository (Integration)", () => { | |
| beforeAll(async () => { | ||
| // Use test database | ||
| await prisma.$connect(); | ||
| repo = new UserRepository(prisma); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Repository API shape now conflicts with architecture reference. This example uses Please align both references to one canonical style (instance + DI or static utility). 🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| afterEach(async () => { | ||
|
|
@@ -256,15 +260,27 @@ describe("UserRepository (Integration)", () => { | |
| ```typescript | ||
| // Command side — test business rules | ||
| describe("CreateOrderHandler", () => { | ||
| let handler: CreateOrderHandler; | ||
| const mockRepo = { save: vi.fn() }; | ||
| const mockEventBus = { publish: vi.fn() }; | ||
|
|
||
| beforeEach(() => { | ||
| handler = new CreateOrderHandler(mockRepo, mockEventBus); | ||
| }); | ||
|
|
||
| it("validates stock before creating order", async () => { | ||
| const handler = new CreateOrderHandler(mockRepo, mockEventBus); | ||
| await expect(handler.execute(new CreateOrderCommand("user-1", [ | ||
| { productId: "p-1", quantity: 999 }, // Exceeds stock | ||
| ]))).rejects.toThrow("Insufficient stock"); | ||
| }); | ||
|
|
||
| it("publishes OrderCreated event", async () => { | ||
| const validCommand = new CreateOrderCommand("user-1", [ | ||
| { productId: "p-1", quantity: 1 }, | ||
| ]); | ||
|
|
||
| await handler.execute(validCommand); | ||
|
|
||
| expect(mockEventBus.publish).toHaveBeenCalledWith( | ||
| expect.objectContaining({ type: "OrderCreated" }) | ||
| ); | ||
|
|
@@ -273,6 +289,13 @@ describe("CreateOrderHandler", () => { | |
|
|
||
| // Query side — test data retrieval | ||
| describe("GetOrderSummaryHandler", () => { | ||
| let handler: GetOrderSummaryHandler; | ||
| const readDb = { query: vi.fn() }; | ||
|
|
||
| beforeEach(() => { | ||
| handler = new GetOrderSummaryHandler(readDb); | ||
| }); | ||
|
Comment on lines
+292
to
+297
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Query handler setup is incomplete for the asserted result shape.
Proposed doc fix beforeEach(() => {
handler = new GetOrderSummaryHandler(readDb);
+ readDb.query.mockResolvedValue({
+ orderId: "ord-1",
+ userName: "Test User",
+ itemCount: 2,
+ });
});🤖 Prompt for AI Agents |
||
|
|
||
| it("returns denormalized view", async () => { | ||
| const result = await handler.execute(new GetOrderSummaryQuery("ord-1")); | ||
| expect(result).toMatchObject({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use “Markdown” capitalization for consistency.
Minor docs polish: “markdown” should be “Markdown”.
🧰 Tools
🪛 LanguageTool
[uncategorized] ~16-~16: Did you mean the formatting language “Markdown” (= proper noun)?
Context: ...d` exist in repository. ## Low - Ran a markdown link/path scan (no actionable broken lo...
(MARKDOWN_NNP)
🤖 Prompt for AI Agents