This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

initial implementation plan for shared types

+974
+974
docs/plan/shared_type_unification.md
··· 1 + # Shared Type Unification Plan - DDD-Aligned Implementation 2 + 3 + ## Executive Summary 4 + 5 + This document provides a comprehensive implementation plan for sharing types between the backend and frontend using npm workspaces. The approach balances DDD/layered architecture principles with pragmatic simplicity for a startup codebase. 6 + 7 + ## Current State Analysis 8 + 9 + ### What Exists Today 10 + 11 + **Backend DTOs** (`src/modules/cards/application/dtos/`): 12 + - `UserProfileDTO`, `UrlCardDTO`, `NoteCardDTO`, `CollectionDTO` 13 + - `PaginationDTO`, `CardSortingDTO`, `CollectionSortingDTO` 14 + - `FeedItemDTO` 15 + - These are used as return types from Use Cases 16 + 17 + **Frontend Types** (`src/webapp/api-client/types/`): 18 + - `requests.ts` - Request parameter interfaces 19 + - `responses.ts` - Response type interfaces (User, UrlCard, Collection, etc.) 20 + - Nearly identical to backend DTOs (already unified in recent work) 21 + 22 + **Current Flow**: 23 + ``` 24 + Domain Model → Use Case (returns DTO) → Controller (returns DTO as-is) → Frontend (expects matching type) 25 + ``` 26 + 27 + ### The Problem 28 + 29 + 1. **Duplication**: Same types exist in backend DTOs and frontend response types 30 + 2. **Drift Risk**: Changes must be manually synchronized between backend and frontend 31 + 3. **No Validation**: Controllers accept `any` types, then use cases validate 32 + 33 + ## Architectural Philosophy 34 + 35 + ### DDD Layered Architecture Review 36 + 37 + **Classic DDD Layers:** 38 + 1. **Domain Layer**: Entities, Value Objects, Domain Services, Domain Events 39 + 2. **Application Layer**: Use Cases, DTOs, Application Services 40 + 3. **Infrastructure Layer**: Controllers, Repositories, External Services 41 + 4. **Presentation Layer**: UI Components, API Clients 42 + 43 + ### Where Types Belong 44 + 45 + **Traditional DDD Approach:** 46 + - DTOs live in **Application Layer** (Use Case outputs) 47 + - DTOs define contract between Use Case and Controller 48 + - DTOs should NOT be directly exposed to external consumers 49 + - Controllers map DTOs to HTTP response formats 50 + 51 + **Pragmatic Reality for Startups:** 52 + - Maintaining separate DTO and HTTP response types is overhead 53 + - For simple CRUD APIs, DTOs ≈ HTTP responses is acceptable 54 + - The key is being **intentional** about this choice 55 + 56 + ### Our Approach: Pragmatic DDD 57 + 58 + We'll create **shared API contract types** that serve dual purposes: 59 + 1. Use Case output types (replacing current DTOs) 60 + 2. HTTP API contract types (replacing frontend response types) 61 + 62 + This is a pragmatic simplification that: 63 + - ✅ Eliminates duplication 64 + - ✅ Maintains type safety 65 + - ✅ Keeps code simple 66 + - ⚠️ Slightly blurs application/infrastructure boundary (acceptable tradeoff) 67 + 68 + ## Validation Strategy in DDD 69 + 70 + ### Three Levels of Validation 71 + 72 + #### 1. Controller-Level Validation (Infrastructure Layer) 73 + **Purpose**: Validate HTTP request structure and types 74 + **Implementation**: Use Zod schemas at controller entry points 75 + **Examples**: 76 + - Is `url` parameter present and a string? 77 + - Is `page` a positive integer? 78 + - Are required fields in request body present? 79 + 80 + **Where**: `src/modules/*/infrastructure/http/controllers/*` 81 + 82 + ```typescript 83 + // Example: GetCollectionsForUrlController.ts 84 + import { z } from 'zod'; 85 + 86 + const querySchema = z.object({ 87 + url: z.string().url(), 88 + page: z.number().int().positive().optional(), 89 + limit: z.number().int().positive().max(100).optional(), 90 + sortBy: z.enum(['name', 'createdAt', 'updatedAt', 'cardCount']).optional(), 91 + sortOrder: z.enum(['asc', 'desc']).optional(), 92 + }); 93 + 94 + async executeImpl(req: Request, res: Response) { 95 + const validationResult = querySchema.safeParse(req.query); 96 + if (!validationResult.success) { 97 + return this.badRequest(res, validationResult.error.message); 98 + } 99 + 100 + const query = validationResult.data; 101 + // Pass validated data to use case... 102 + } 103 + ``` 104 + 105 + #### 2. Use Case-Level Validation (Application Layer) 106 + **Purpose**: Validate business rules and create domain objects 107 + **Implementation**: Use domain value objects (already doing this!) 108 + **Examples**: 109 + - `URL.create(query.url)` - validates URL format using domain rules 110 + - `CardId.createFromString(id)` - validates ID format 111 + - Business logic validation (e.g., "user can only have 50 collections") 112 + 113 + **Where**: `src/modules/*/application/useCases/*` 114 + 115 + This layer is **already correct** in the codebase! 116 + 117 + #### 3. Domain-Level Validation (Domain Layer) 118 + **Purpose**: Enforce invariants and domain rules 119 + **Implementation**: Value Objects and Entity constructors 120 + **Examples**: 121 + - `URL` value object validates URL format 122 + - `CollectionName` might enforce length limits 123 + - `Email` value object validates email format 124 + 125 + **Where**: `src/modules/*/domain/value-objects/*`, entities 126 + 127 + This layer is **already correct** in the codebase! 128 + 129 + ### Validation Summary 130 + 131 + **Current state**: ✅ Good DDD validation (levels 2 & 3 already implemented) 132 + **What we'll add**: Level 1 (Zod schemas at controllers) for better error messages 133 + 134 + ## Proposed Architecture 135 + 136 + ### Directory Structure 137 + 138 + ``` 139 + semble/ 140 + ├── package.json # Root workspace config 141 + ├── src/ 142 + │ ├── types/ # @semble/types package (NEW) 143 + │ │ ├── package.json 144 + │ │ ├── tsconfig.json 145 + │ │ ├── src/ 146 + │ │ │ ├── api/ 147 + │ │ │ │ ├── common.ts # User, Pagination, Sorting 148 + │ │ │ │ ├── requests.ts # Request types 149 + │ │ │ │ ├── responses.ts # Response types 150 + │ │ │ │ ├── index.ts # Re-exports 151 + │ │ │ └── index.ts # Main entry 152 + │ │ └── dist/ # Compiled output 153 + │ │ 154 + │ ├── modules/ 155 + │ │ ├── cards/ 156 + │ │ │ ├── domain/ # Keep domain layer pure 157 + │ │ │ ├── application/ 158 + │ │ │ │ ├── useCases/ 159 + │ │ │ │ │ └── queries/ 160 + │ │ │ │ │ └── GetCollectionsForUrlUseCase.ts 161 + │ │ │ │ │ # Now returns @semble/types 162 + │ │ │ │ └── dtos/ # REMOVE (replaced by @semble/types) 163 + │ │ │ └── infrastructure/ 164 + │ │ │ └── http/ 165 + │ │ │ └── controllers/ # Add Zod validation 166 + │ │ │ └── GetCollectionsForUrlController.ts 167 + │ │ └── feeds/ 168 + │ │ └── ... (similar structure) 169 + │ │ 170 + │ ├── webapp/ 171 + │ │ ├── package.json # Adds @semble/types dependency 172 + │ │ └── api-client/ 173 + │ │ ├── ApiClient.ts # Imports @semble/types 174 + │ │ └── types/ # REMOVE (replaced by @semble/types) 175 + │ │ 176 + │ └── shared/ # Keep existing backend shared utils 177 + ``` 178 + 179 + ### Type Organization 180 + 181 + **`src/types/src/api/common.ts`** - Shared domain concepts: 182 + ```typescript 183 + export interface User { 184 + id: string; 185 + name: string; 186 + handle: string; 187 + avatarUrl?: string; 188 + description?: string; 189 + } 190 + 191 + export interface Pagination { 192 + currentPage: number; 193 + totalPages: number; 194 + totalCount: number; 195 + hasMore: boolean; 196 + limit: number; 197 + } 198 + 199 + export interface FeedPagination extends Pagination { 200 + nextCursor?: string; 201 + } 202 + 203 + export interface BaseSorting { 204 + sortOrder: 'asc' | 'desc'; 205 + } 206 + 207 + export interface CardSorting extends BaseSorting { 208 + sortBy: 'createdAt' | 'updatedAt' | 'libraryCount'; 209 + } 210 + 211 + export interface CollectionSorting extends BaseSorting { 212 + sortBy: 'name' | 'createdAt' | 'updatedAt' | 'cardCount'; 213 + } 214 + ``` 215 + 216 + **`src/types/src/api/requests.ts`** - API request types: 217 + ```typescript 218 + // Base interfaces 219 + export interface PaginationParams { 220 + page?: number; 221 + limit?: number; 222 + } 223 + 224 + export interface SortingParams { 225 + sortBy?: string; 226 + sortOrder?: 'asc' | 'desc'; 227 + } 228 + 229 + // Query parameter interfaces 230 + export interface GetCollectionsForUrlParams extends PaginationParams, SortingParams { 231 + url: string; 232 + } 233 + 234 + // Command request interfaces 235 + export interface AddUrlToLibraryRequest { 236 + url: string; 237 + note?: string; 238 + collectionIds?: string[]; 239 + } 240 + 241 + // ... etc 242 + ``` 243 + 244 + **`src/types/src/api/responses.ts`** - API response types: 245 + ```typescript 246 + import { User, Pagination, CardSorting, CollectionSorting } from './common'; 247 + 248 + export interface UrlCard { 249 + id: string; 250 + type: 'URL'; 251 + url: string; 252 + cardContent: { 253 + url: string; 254 + title?: string; 255 + description?: string; 256 + author?: string; 257 + thumbnailUrl?: string; 258 + }; 259 + libraryCount: number; 260 + urlLibraryCount: number; 261 + urlInLibrary?: boolean; 262 + createdAt: string; 263 + updatedAt: string; 264 + author: User; 265 + note?: { 266 + id: string; 267 + text: string; 268 + }; 269 + } 270 + 271 + export interface Collection { 272 + id: string; 273 + uri?: string; 274 + name: string; 275 + author: User; 276 + description?: string; 277 + cardCount: number; 278 + createdAt: string; 279 + updatedAt: string; 280 + } 281 + 282 + export interface GetCollectionsForUrlResponse { 283 + collections: Collection[]; 284 + pagination: Pagination; 285 + sorting: CollectionSorting; 286 + } 287 + 288 + // ... etc 289 + ``` 290 + 291 + ## Implementation Plan 292 + 293 + ### Phase 0: Prerequisites 294 + 295 + **Install Zod for validation:** 296 + ```bash 297 + npm install zod 298 + ``` 299 + 300 + ### Phase 1: Create Shared Types Package (Infrastructure) 301 + 302 + #### 1.1 Configure Workspace Root 303 + 304 + Update `package.json`: 305 + ```json 306 + { 307 + "name": "semble", 308 + "workspaces": [ 309 + "src/types", 310 + "src/webapp" 311 + ], 312 + "scripts": { 313 + "build:types": "npm run build --workspace=@semble/types", 314 + "dev:types": "npm run dev --workspace=@semble/types", 315 + "type-check": "tsc --noEmit && npm run type-check --workspace=@semble/webapp" 316 + } 317 + } 318 + ``` 319 + 320 + #### 1.2 Create Types Package 321 + 322 + Create `src/types/package.json`: 323 + ```json 324 + { 325 + "name": "@semble/types", 326 + "version": "1.0.0", 327 + "description": "Shared TypeScript types for Semble API", 328 + "main": "dist/index.js", 329 + "types": "dist/index.d.ts", 330 + "files": ["dist/**/*"], 331 + "scripts": { 332 + "build": "tsc", 333 + "dev": "tsc --watch", 334 + "clean": "rm -rf dist" 335 + }, 336 + "devDependencies": { 337 + "typescript": "^5.8.3" 338 + } 339 + } 340 + ``` 341 + 342 + Create `src/types/tsconfig.json`: 343 + ```json 344 + { 345 + "compilerOptions": { 346 + "target": "ES2020", 347 + "module": "CommonJS", 348 + "lib": ["ES2020"], 349 + "outDir": "./dist", 350 + "rootDir": "./src", 351 + "strict": true, 352 + "esModuleInterop": true, 353 + "skipLibCheck": true, 354 + "forceConsistentCasingInFileNames": true, 355 + "declaration": true, 356 + "declarationMap": true, 357 + "sourceMap": true 358 + }, 359 + "include": ["src/**/*"], 360 + "exclude": ["node_modules", "dist"] 361 + } 362 + ``` 363 + 364 + #### 1.3 Copy Types from Frontend 365 + 366 + 1. Copy content from `src/webapp/api-client/types/` to `src/types/src/api/` 367 + 2. Organize into `common.ts`, `requests.ts`, `responses.ts` 368 + 3. Create index files for re-exports 369 + 4. Build: `cd src/types && npm run build` 370 + 371 + ### Phase 2: Update Backend to Use Shared Types 372 + 373 + #### 2.1 Add Dependency 374 + 375 + Update root `package.json`: 376 + ```json 377 + { 378 + "dependencies": { 379 + "@semble/types": "workspace:*", 380 + "zod": "^3.22.4" 381 + } 382 + } 383 + ``` 384 + 385 + Run `npm install` 386 + 387 + #### 2.2 Update Use Cases 388 + 389 + **Example: `GetCollectionsForUrlUseCase.ts`** 390 + 391 + Before: 392 + ```typescript 393 + import { CollectionDTO, PaginationDTO, CollectionSortingDTO } from '../../dtos'; 394 + 395 + export interface GetCollectionsForUrlResult { 396 + collections: CollectionDTO[]; 397 + pagination: PaginationDTO; 398 + sorting: CollectionSortingDTO; 399 + } 400 + ``` 401 + 402 + After: 403 + ```typescript 404 + import { GetCollectionsForUrlResponse } from '@semble/types'; 405 + 406 + // Use the shared type directly as the return type 407 + export type GetCollectionsForUrlResult = GetCollectionsForUrlResponse; 408 + 409 + // Or inline it: 410 + async execute(query: GetCollectionsForUrlQuery): Promise<Result<GetCollectionsForUrlResponse>> { 411 + // ... implementation returns GetCollectionsForUrlResponse 412 + } 413 + ``` 414 + 415 + #### 2.3 Update Controllers with Validation 416 + 417 + **Example: `GetCollectionsForUrlController.ts`** 418 + 419 + Before: 420 + ```typescript 421 + async executeImpl(req: Request, res: Response): Promise<any> { 422 + const { url } = req.query; 423 + if (!url || typeof url !== 'string') { 424 + return this.badRequest(res, 'URL query parameter is required'); 425 + } 426 + // ... 427 + } 428 + ``` 429 + 430 + After: 431 + ```typescript 432 + import { z } from 'zod'; 433 + import { GetCollectionsForUrlParams, GetCollectionsForUrlResponse } from '@semble/types'; 434 + 435 + // Define validation schema 436 + const querySchema = z.object({ 437 + url: z.string().min(1, 'URL is required'), 438 + page: z.coerce.number().int().positive().optional(), 439 + limit: z.coerce.number().int().positive().max(100).optional(), 440 + sortBy: z.enum(['name', 'createdAt', 'updatedAt', 'cardCount']).optional(), 441 + sortOrder: z.enum(['asc', 'desc']).optional(), 442 + }); 443 + 444 + async executeImpl(req: Request, res: Response): Promise<any> { 445 + // Validate request 446 + const validation = querySchema.safeParse(req.query); 447 + if (!validation.success) { 448 + return this.badRequest(res, validation.error.format()); 449 + } 450 + 451 + const params: GetCollectionsForUrlParams = validation.data; 452 + 453 + const result = await this.getCollectionsForUrlUseCase.execute({ 454 + url: params.url, 455 + page: params.page, 456 + limit: params.limit, 457 + sortBy: params.sortBy as CollectionSortField | undefined, 458 + sortOrder: params.sortOrder as SortOrder | undefined, 459 + }); 460 + 461 + if (result.isErr()) { 462 + return this.fail(res, result.error); 463 + } 464 + 465 + // result.value is GetCollectionsForUrlResponse 466 + return this.ok<GetCollectionsForUrlResponse>(res, result.value); 467 + } 468 + ``` 469 + 470 + #### 2.4 Remove Old DTOs 471 + 472 + Once all use cases and controllers are updated: 473 + ```bash 474 + rm -rf src/modules/cards/application/dtos/ 475 + rm -rf src/modules/user/application/dtos/ 476 + ``` 477 + 478 + ### Phase 3: Update Frontend to Use Shared Types 479 + 480 + #### 3.1 Add Dependency 481 + 482 + Update `src/webapp/package.json`: 483 + ```json 484 + { 485 + "dependencies": { 486 + "@semble/types": "workspace:*" 487 + } 488 + } 489 + ``` 490 + 491 + Run `npm install` 492 + 493 + #### 3.2 Update ApiClient 494 + 495 + **`src/webapp/api-client/ApiClient.ts`** 496 + 497 + Before: 498 + ```typescript 499 + import type { 500 + GetCollectionsForUrlParams, 501 + GetCollectionsForUrlResponse, 502 + } from './types'; 503 + ``` 504 + 505 + After: 506 + ```typescript 507 + import type { 508 + GetCollectionsForUrlParams, 509 + GetCollectionsForUrlResponse, 510 + } from '@semble/types'; 511 + ``` 512 + 513 + #### 3.3 Remove Old Types 514 + 515 + ```bash 516 + rm -rf src/webapp/api-client/types/ 517 + ``` 518 + 519 + ### Phase 4: Development Workflow Setup 520 + 521 + #### 4.1 Add Development Scripts 522 + 523 + Update root `package.json`: 524 + ```json 525 + { 526 + "scripts": { 527 + "dev": "concurrently \"npm run dev:types\" \"npm run dev:webapp\" \"npm run dev:backend\"", 528 + "dev:types": "npm run dev --workspace=@semble/types", 529 + "dev:webapp": "npm run dev --workspace=@semble/webapp", 530 + "dev:backend": "npm run dev:app:inner", 531 + "build:all": "npm run build:types && npm run build && npm run build:webapp", 532 + "type-check": "tsc --noEmit && npm run type-check --workspace=@semble/webapp" 533 + } 534 + } 535 + ``` 536 + 537 + #### 4.2 Update Backend tsconfig.json 538 + 539 + Ensure backend can resolve workspace packages: 540 + ```json 541 + { 542 + "compilerOptions": { 543 + "paths": { 544 + "@semble/types": ["./src/types/src"] 545 + } 546 + } 547 + } 548 + ``` 549 + 550 + ### Phase 5: Testing & Validation 551 + 552 + #### 5.1 Type Checking 553 + ```bash 554 + npm run type-check # Check backend 555 + npm run type-check --workspace=@semble/webapp # Check frontend 556 + ``` 557 + 558 + #### 5.2 Build Everything 559 + ```bash 560 + npm run build:all 561 + ``` 562 + 563 + #### 5.3 Runtime Testing 564 + - Start dev servers: `npm run dev` 565 + - Test each modified endpoint 566 + - Verify request validation works (try invalid requests) 567 + - Verify responses match expected types 568 + 569 + ## Migration Checklist 570 + 571 + ### Phase 1: Shared Types Package ✅ 572 + - [ ] Update root `package.json` with workspaces configuration 573 + - [ ] Create `src/types/package.json` 574 + - [ ] Create `src/types/tsconfig.json` 575 + - [ ] Create `src/types/src/api/common.ts` (copy from webapp) 576 + - [ ] Create `src/types/src/api/requests.ts` (copy from webapp) 577 + - [ ] Create `src/types/src/api/responses.ts` (copy from webapp) 578 + - [ ] Create `src/types/src/api/index.ts` (re-exports) 579 + - [ ] Create `src/types/src/index.ts` (main entry) 580 + - [ ] Build types: `cd src/types && npm install && npm run build` 581 + - [ ] Verify build output in `src/types/dist/` 582 + 583 + ### Phase 2: Backend Migration ✅ 584 + - [ ] Install zod: `npm install zod` 585 + - [ ] Add `@semble/types` to root dependencies 586 + - [ ] Run `npm install` to link workspace 587 + - [ ] Update backend tsconfig.json with paths 588 + - [ ] For each module (cards, feeds, user): 589 + - [ ] Update use case imports (replace DTO imports with @semble/types) 590 + - [ ] Update use case return types to use shared types 591 + - [ ] Update controller imports 592 + - [ ] Add Zod validation schemas to controllers 593 + - [ ] Update controller methods to use validated params 594 + - [ ] Type controller responses 595 + - [ ] Remove `src/modules/*/application/dtos/` directories 596 + - [ ] Run type check: `npm run type-check` 597 + - [ ] Fix any type errors 598 + 599 + ### Phase 3: Frontend Migration ✅ 600 + - [ ] Add `@semble/types` to `src/webapp/package.json` 601 + - [ ] Run `npm install` in webapp 602 + - [ ] Update `src/webapp/api-client/ApiClient.ts` imports 603 + - [ ] Update all client files in `src/webapp/api-client/clients/` 604 + - [ ] Remove `src/webapp/api-client/types/` directory 605 + - [ ] Update any UI components that imported from old types 606 + - [ ] Run type check: `npm run type-check --workspace=@semble/webapp` 607 + - [ ] Fix any type errors 608 + 609 + ### Phase 4: Development Workflow ✅ 610 + - [ ] Add dev scripts to root package.json 611 + - [ ] Test concurrent development: `npm run dev` 612 + - [ ] Make a test change to shared types 613 + - [ ] Verify hot reload works in both backend and frontend 614 + - [ ] Test build pipeline: `npm run build:all` 615 + 616 + ### Phase 5: Testing ✅ 617 + - [ ] Backend tests pass 618 + - [ ] Frontend tests pass 619 + - [ ] Manual testing of key endpoints: 620 + - [ ] GET /api/cards/collections/for-url 621 + - [ ] GET /api/cards/my-cards 622 + - [ ] GET /api/feed/global 623 + - [ ] POST /api/cards/library/url 624 + - [ ] POST /api/collections 625 + - [ ] Test request validation (submit invalid requests) 626 + - [ ] Verify error messages are helpful 627 + 628 + ## Files to Modify 629 + 630 + ### High Priority (Core Queries) 631 + 632 + **Use Cases:** 633 + - `src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts` 634 + - `src/modules/cards/application/useCases/queries/GetCollectionsUseCase.ts` 635 + - `src/modules/cards/application/useCases/queries/GetLibrariesForCardUseCase.ts` 636 + - `src/modules/cards/application/useCases/queries/GetLibrariesForUrlUseCase.ts` 637 + - `src/modules/cards/application/useCases/queries/GetNoteCardsForUrlUseCase.ts` 638 + - `src/modules/cards/application/useCases/queries/GetUrlCardViewUseCase.ts` 639 + - `src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts` 640 + - `src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts` 641 + 642 + **Controllers:** 643 + - All controllers in `src/modules/cards/infrastructure/http/controllers/` 644 + - `src/modules/feeds/infrastructure/http/controllers/GetGlobalFeedController.ts` 645 + 646 + **Frontend:** 647 + - `src/webapp/api-client/ApiClient.ts` 648 + - All files in `src/webapp/api-client/clients/` 649 + 650 + ### Medium Priority (Commands) 651 + 652 + **Use Cases:** 653 + - `src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts` 654 + - `src/modules/cards/application/useCases/commands/CreateCollectionUseCase.ts` 655 + - ... (other command use cases) 656 + 657 + ### Lower Priority 658 + 659 + - UI components that reference types directly 660 + - Test files 661 + - Documentation 662 + 663 + ## Best Practices 664 + 665 + ### Type Naming Conventions 666 + - **Requests**: `{Verb}{Resource}Request` (e.g., `AddUrlToLibraryRequest`) 667 + - **Params**: `Get{Resource}Params` (e.g., `GetCollectionsForUrlParams`) 668 + - **Responses**: `{Verb}{Resource}Response` (e.g., `GetCollectionsForUrlResponse`) 669 + - **Common**: Descriptive nouns (e.g., `User`, `Pagination`, `Collection`) 670 + 671 + ### Validation Patterns 672 + 673 + **Controller validation (structure & types):** 674 + ```typescript 675 + const schema = z.object({ 676 + requiredString: z.string().min(1), 677 + optionalNumber: z.coerce.number().optional(), 678 + enum: z.enum(['option1', 'option2']), 679 + }); 680 + 681 + const result = schema.safeParse(req.body); 682 + if (!result.success) { 683 + return this.badRequest(res, result.error.format()); 684 + } 685 + ``` 686 + 687 + **Use case validation (business rules):** 688 + ```typescript 689 + const urlResult = URL.create(params.url); 690 + if (urlResult.isErr()) { 691 + return err(new ValidationError(`Invalid URL: ${urlResult.error.message}`)); 692 + } 693 + ``` 694 + 695 + ### Development Workflow 696 + 1. **Always run types in watch mode** during development: `npm run dev:types` 697 + 2. **Make type changes first** before implementing features 698 + 3. **Type-check frequently**: `npm run type-check` 699 + 4. **Test request validation** with invalid inputs 700 + 701 + ## Pragmatic Simplifications 702 + 703 + ### What We're NOT Doing (And Why That's OK) 704 + 705 + ❌ **Separate DTOs and API types**: 706 + - Reason: For this codebase, they're effectively the same 707 + - Tradeoff: Slightly impure DDD, but significantly simpler 708 + 709 + ❌ **Runtime validation of responses**: 710 + - Reason: TypeScript compilation guarantees response shape 711 + - Tradeoff: Could add Zod validation of Use Case outputs, but not needed initially 712 + 713 + ❌ **OpenAPI schema generation**: 714 + - Reason: Can add later if needed 715 + - Benefit: Shared types make this easier in the future 716 + 717 + ❌ **Separate versioning of types package**: 718 + - Reason: Monorepo with synchronized deploys 719 + - Note: Could add semantic versioning later if needed 720 + 721 + ### What Makes This "Barebones But Reliable" 722 + 723 + ✅ **Single source of truth**: Types defined once, used everywhere 724 + ✅ **Compile-time safety**: TypeScript catches mismatches immediately 725 + ✅ **Runtime validation**: Zod at boundaries catches bad requests 726 + ✅ **Simple workflow**: Edit types, both sides update automatically 727 + ✅ **Standard tooling**: npm workspaces (not custom build scripts) 728 + 729 + ## Troubleshooting 730 + 731 + ### "Cannot find module '@semble/types'" 732 + ```bash 733 + # From root 734 + npm install 735 + cd src/types && npm run build 736 + ``` 737 + 738 + ### Types package not updating 739 + ```bash 740 + # Restart types watch mode 741 + npm run dev:types 742 + ``` 743 + 744 + ### Import path errors in IDE 745 + - Restart TypeScript server in your IDE 746 + - Check `tsconfig.json` paths configuration 747 + - Verify `node_modules/@semble/types` symlink exists 748 + 749 + ### Validation errors not showing 750 + - Check Zod schema matches the expected request shape 751 + - Use `.safeParse()` not `.parse()` to get detailed errors 752 + - Log `validation.error.format()` for debugging 753 + 754 + ## Future Enhancements 755 + 756 + ### Short Term 757 + - [ ] Add Zod schemas for all controllers 758 + - [ ] Create shared Zod utilities for common patterns (pagination, sorting) 759 + - [ ] Add request/response logging middleware 760 + 761 + ### Medium Term 762 + - [ ] Generate OpenAPI spec from shared types 763 + - [ ] Create API documentation from types 764 + - [ ] Add integration tests using shared types 765 + - [ ] Create type-safe test factories 766 + 767 + ### Long Term 768 + - [ ] Publish types package to private npm registry 769 + - [ ] Implement breaking change detection (type diff checks) 770 + - [ ] Add runtime response validation in development mode 771 + - [ ] Generate client SDKs for mobile apps 772 + 773 + ## DDD Architecture Notes 774 + 775 + ### What We Preserved 776 + - ✅ Domain layer remains pure (no external dependencies) 777 + - ✅ Use cases validate using domain value objects 778 + - ✅ Domain-driven validation hierarchy (domain → application → infrastructure) 779 + - ✅ Clear boundaries between layers 780 + 781 + ### Pragmatic Compromises 782 + - ⚠️ Shared types blur application/infrastructure boundary 783 + - ⚠️ Use cases return types designed for HTTP responses 784 + - ✅ But: This is **intentional** and **documented** 785 + 786 + ### Why This Is OK for a Startup 787 + 1. **Velocity**: Eliminates significant boilerplate 788 + 2. **Type Safety**: Maintains compile-time guarantees 789 + 3. **Maintainability**: Single source of truth reduces bugs 790 + 4. **Scalability**: Can refactor later if needed (types are internal contract) 791 + 792 + The key is being **intentional** about the tradeoff rather than accidentally coupling layers. 793 + 794 + --- 795 + 796 + ## Appendix: Example Migration 797 + 798 + ### Before: GetCollectionsForUrlUseCase 799 + 800 + ```typescript 801 + // src/modules/cards/application/dtos/CollectionDTO.ts 802 + export interface CollectionDTO { 803 + id: string; 804 + uri?: string; 805 + name: string; 806 + author: UserProfileDTO; 807 + description?: string; 808 + cardCount: number; 809 + createdAt: string; 810 + updatedAt: string; 811 + } 812 + 813 + // src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts 814 + import { CollectionDTO, PaginationDTO, CollectionSortingDTO } from '../../dtos'; 815 + 816 + export interface GetCollectionsForUrlResult { 817 + collections: CollectionDTO[]; 818 + pagination: PaginationDTO; 819 + sorting: CollectionSortingDTO; 820 + } 821 + 822 + export class GetCollectionsForUrlUseCase { 823 + async execute(query: GetCollectionsForUrlQuery): Promise<Result<GetCollectionsForUrlResult>> { 824 + // ... implementation 825 + } 826 + } 827 + 828 + // src/modules/cards/infrastructure/http/controllers/GetCollectionsForUrlController.ts 829 + export class GetCollectionsForUrlController extends Controller { 830 + async executeImpl(req: Request, res: Response): Promise<any> { 831 + const { url } = req.query; 832 + if (!url || typeof url !== 'string') { 833 + return this.badRequest(res, 'URL query parameter is required'); 834 + } 835 + 836 + const result = await this.useCase.execute({ url }); 837 + if (result.isErr()) { 838 + return this.fail(res, result.error); 839 + } 840 + return this.ok(res, result.value); 841 + } 842 + } 843 + 844 + // src/webapp/api-client/types/responses.ts 845 + export interface Collection { 846 + id: string; 847 + uri?: string; 848 + name: string; 849 + author: User; 850 + description?: string; 851 + cardCount: number; 852 + createdAt: string; 853 + updatedAt: string; 854 + } 855 + 856 + export interface GetCollectionsForUrlResponse { 857 + collections: Collection[]; 858 + pagination: Pagination; 859 + sorting: CollectionSorting; 860 + } 861 + 862 + // src/webapp/api-client/ApiClient.ts 863 + import { GetCollectionsForUrlResponse } from './types'; 864 + 865 + async getCollectionsForUrl(params): Promise<GetCollectionsForUrlResponse> { 866 + // ... 867 + } 868 + ``` 869 + 870 + ### After: With Shared Types 871 + 872 + ```typescript 873 + // src/types/src/api/responses.ts 874 + export interface Collection { 875 + id: string; 876 + uri?: string; 877 + name: string; 878 + author: User; 879 + description?: string; 880 + cardCount: number; 881 + createdAt: string; 882 + updatedAt: string; 883 + } 884 + 885 + export interface GetCollectionsForUrlResponse { 886 + collections: Collection[]; 887 + pagination: Pagination; 888 + sorting: CollectionSorting; 889 + } 890 + 891 + // src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts 892 + import { GetCollectionsForUrlResponse } from '@semble/types'; 893 + 894 + export class GetCollectionsForUrlUseCase { 895 + async execute( 896 + query: GetCollectionsForUrlQuery 897 + ): Promise<Result<GetCollectionsForUrlResponse>> { 898 + // ... implementation returns GetCollectionsForUrlResponse 899 + } 900 + } 901 + 902 + // src/modules/cards/infrastructure/http/controllers/GetCollectionsForUrlController.ts 903 + import { z } from 'zod'; 904 + import { 905 + GetCollectionsForUrlParams, 906 + GetCollectionsForUrlResponse 907 + } from '@semble/types'; 908 + 909 + const querySchema = z.object({ 910 + url: z.string().min(1), 911 + page: z.coerce.number().int().positive().optional(), 912 + limit: z.coerce.number().int().positive().max(100).optional(), 913 + sortBy: z.enum(['name', 'createdAt', 'updatedAt', 'cardCount']).optional(), 914 + sortOrder: z.enum(['asc', 'desc']).optional(), 915 + }); 916 + 917 + export class GetCollectionsForUrlController extends Controller { 918 + async executeImpl(req: Request, res: Response): Promise<any> { 919 + const validation = querySchema.safeParse(req.query); 920 + if (!validation.success) { 921 + return this.badRequest(res, validation.error.format()); 922 + } 923 + 924 + const result = await this.useCase.execute({ 925 + url: validation.data.url, 926 + page: validation.data.page, 927 + limit: validation.data.limit, 928 + sortBy: validation.data.sortBy as CollectionSortField | undefined, 929 + sortOrder: validation.data.sortOrder as SortOrder | undefined, 930 + }); 931 + 932 + if (result.isErr()) { 933 + return this.fail(res, result.error); 934 + } 935 + 936 + return this.ok<GetCollectionsForUrlResponse>(res, result.value); 937 + } 938 + } 939 + 940 + // src/webapp/api-client/ApiClient.ts 941 + import { GetCollectionsForUrlResponse } from '@semble/types'; 942 + 943 + async getCollectionsForUrl(params): Promise<GetCollectionsForUrlResponse> { 944 + // ... 945 + } 946 + ``` 947 + 948 + ### Key Changes 949 + 1. ✅ Single `Collection` type (not duplicated) 950 + 2. ✅ Single `GetCollectionsForUrlResponse` type 951 + 3. ✅ Controller validates with Zod schema 952 + 4. ✅ Use case returns shared type 953 + 5. ✅ Frontend imports same types 954 + 6. ✅ Full type safety from DB to UI 955 + 956 + --- 957 + 958 + ## Summary 959 + 960 + This plan provides a **pragmatic, DDD-aligned approach** to sharing types between backend and frontend: 961 + 962 + - **Uses npm workspaces** for proper dependency management 963 + - **Adds Zod validation** at controller boundaries 964 + - **Preserves domain-driven validation** patterns 965 + - **Eliminates type duplication** while maintaining type safety 966 + - **Stays simple** and avoids overengineering 967 + 968 + The result is a **barebones but reliable** type system that: 969 + - Catches errors at compile time 970 + - Validates requests at runtime 971 + - Maintains a single source of truth 972 + - Scales as the codebase grows 973 + 974 + Your engineer should be able to follow this plan step-by-step to implement the shared type system successfully.