240 lines
No EOL
6.2 KiB
Markdown
240 lines
No EOL
6.2 KiB
Markdown
# Clean Architecture Plan: Type-Safe Data Flow with Single Source of Truth
|
|
|
|
## Analysis Summary
|
|
|
|
After examining the Rails blueprints and current implementation, I've identified the key issues:
|
|
|
|
1. **Type Redundancy**: We have multiple types for the same entities (Party vs PartyView, GridWeapon vs GridWeaponItemView)
|
|
2. **Inconsistent Naming**: The API uses `object` to refer to nested entities (weapon, character, summon)
|
|
3. **Complex Validation**: The parseParty function does too much transformation and validation
|
|
4. **Hydration Issues**: Server and client compute different values due to timing and data availability
|
|
|
|
## Proposed Architecture
|
|
|
|
### 1. Single Source of Truth for Types
|
|
|
|
Create clean, single type definitions based on the Rails blueprints:
|
|
|
|
```typescript
|
|
// Core entities (from blueprints)
|
|
interface Weapon {
|
|
id: string
|
|
granblueId: string
|
|
name: LocalizedName
|
|
element: number
|
|
proficiency: number
|
|
rarity: number
|
|
// ... all fields from WeaponBlueprint
|
|
}
|
|
|
|
interface Character {
|
|
id: string
|
|
granblueId: string
|
|
name: LocalizedName
|
|
element: number
|
|
rarity: number
|
|
// ... all fields from CharacterBlueprint
|
|
}
|
|
|
|
interface Summon {
|
|
id: string
|
|
granblueId: string
|
|
name: LocalizedName
|
|
element: number
|
|
rarity: number
|
|
// ... all fields from SummonBlueprint
|
|
}
|
|
|
|
// Grid items (relationships)
|
|
interface GridWeapon {
|
|
id: string
|
|
position: number
|
|
mainhand?: boolean
|
|
uncapLevel?: number
|
|
transcendenceStep?: number
|
|
element?: number
|
|
weapon: Weapon // Named properly, not "object"
|
|
weaponKeys?: WeaponKey[]
|
|
// ... fields from GridWeaponBlueprint
|
|
}
|
|
|
|
interface GridCharacter {
|
|
id: string
|
|
position: number
|
|
uncapLevel?: number
|
|
perpetuity?: boolean
|
|
transcendenceStep?: number
|
|
character: Character // Named properly, not "object"
|
|
awakening?: Awakening
|
|
// ... fields from GridCharacterBlueprint
|
|
}
|
|
|
|
interface GridSummon {
|
|
id: string
|
|
position: number
|
|
main?: boolean
|
|
friend?: boolean
|
|
quickSummon?: boolean
|
|
uncapLevel?: number
|
|
summon: Summon // Named properly, not "object"
|
|
// ... fields from GridSummonBlueprint
|
|
}
|
|
|
|
interface Party {
|
|
id: string
|
|
shortcode: string
|
|
name?: string
|
|
description?: string
|
|
weapons: GridWeapon[]
|
|
characters: GridCharacter[]
|
|
summons: GridSummon[]
|
|
job?: Job
|
|
raid?: Raid
|
|
// ... all fields from PartyBlueprint
|
|
}
|
|
```
|
|
|
|
### 2. Automatic Case Transformation Layer
|
|
|
|
Create a type-safe API client that handles transformations automatically:
|
|
|
|
```typescript
|
|
// api/client.ts
|
|
class ApiClient {
|
|
private transformResponse<T>(data: any): T {
|
|
// Transform snake_case to camelCase
|
|
const camelCased = snakeToCamel(data)
|
|
|
|
// Rename "object" to proper entity names
|
|
if (camelCased.weapons) {
|
|
camelCased.weapons = camelCased.weapons.map(w => ({
|
|
...w,
|
|
weapon: w.object,
|
|
object: undefined
|
|
}))
|
|
}
|
|
if (camelCased.characters) {
|
|
camelCased.characters = camelCased.characters.map(c => ({
|
|
...c,
|
|
character: c.object,
|
|
object: undefined
|
|
}))
|
|
}
|
|
if (camelCased.summons) {
|
|
camelCased.summons = camelCased.summons.map(s => ({
|
|
...s,
|
|
summon: s.object,
|
|
object: undefined
|
|
}))
|
|
}
|
|
|
|
return camelCased as T
|
|
}
|
|
|
|
private transformRequest<T>(data: T): any {
|
|
// Transform camelCase to snake_case
|
|
// Rename entity names back to "object" for API
|
|
const prepared = {
|
|
...data,
|
|
weapons: data.weapons?.map(w => ({
|
|
...w,
|
|
object: w.weapon,
|
|
weapon: undefined
|
|
})),
|
|
// Similar for characters and summons
|
|
}
|
|
|
|
return camelToSnake(prepared)
|
|
}
|
|
|
|
async get<T>(path: string): Promise<T> {
|
|
const response = await fetch(path)
|
|
const data = await response.json()
|
|
return this.transformResponse<T>(data)
|
|
}
|
|
|
|
async post<T>(path: string, body: any): Promise<T> {
|
|
const response = await fetch(path, {
|
|
body: JSON.stringify(this.transformRequest(body))
|
|
})
|
|
const data = await response.json()
|
|
return this.transformResponse<T>(data)
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Service Layer
|
|
|
|
Create clean service interfaces that return properly typed data:
|
|
|
|
```typescript
|
|
// services/party.service.ts
|
|
export class PartyService {
|
|
constructor(private client: ApiClient) {}
|
|
|
|
async getByShortcode(shortcode: string): Promise<Party> {
|
|
// Client handles all transformations
|
|
return this.client.get<Party>(`/parties/${shortcode}`)
|
|
}
|
|
|
|
async update(id: string, updates: Partial<Party>): Promise<Party> {
|
|
return this.client.put<Party>(`/parties/${id}`, updates)
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Component Updates
|
|
|
|
Components use the clean, properly typed interfaces:
|
|
|
|
```typescript
|
|
// Party.svelte
|
|
interface Props {
|
|
initial: Party // Single Party type, no confusion
|
|
}
|
|
|
|
// WeaponUnit.svelte
|
|
interface Props {
|
|
item: GridWeapon // Properly typed with weapon property
|
|
}
|
|
|
|
// Access data cleanly:
|
|
const imageUrl = item.weapon.granblueId
|
|
const name = item.weapon.name
|
|
```
|
|
|
|
## Implementation Steps
|
|
|
|
1. **Create new type definitions** in `/src/lib/types/`:
|
|
- `entities.ts` - Core entities (Weapon, Character, Summon, etc.)
|
|
- `grid.ts` - Grid items (GridWeapon, GridCharacter, GridSummon)
|
|
- `party.ts` - Party and related types
|
|
|
|
2. **Update API client** in `/src/lib/api/`:
|
|
- Add transformation logic to handle `object` → entity name mapping
|
|
- Keep snake_case/camelCase transformation
|
|
- Make it type-safe with generics
|
|
|
|
3. **Simplify parseParty**:
|
|
- Remove validation schemas
|
|
- Just call the API client's transform method
|
|
- Trust the API data structure
|
|
|
|
4. **Update components**:
|
|
- Use the new single types everywhere
|
|
- Access `item.weapon.granblueId` instead of `item.object.granblueId`
|
|
- Remove all the `as any` casts
|
|
|
|
5. **Remove redundant types**:
|
|
- Delete PartyView, GridWeaponItemView, etc.
|
|
- Use only the new clean types
|
|
|
|
## Benefits
|
|
|
|
✅ **Single source of truth** - One type per entity, no confusion
|
|
✅ **Type safety** - Full TypeScript benefits with proper types
|
|
✅ **Clean property names** - `weapon`, `character`, `summon` instead of `object`
|
|
✅ **Automatic transformations** - Handle case conversion in one place
|
|
✅ **No hydration issues** - Consistent data structure everywhere
|
|
✅ **Maintainable** - Clear separation of concerns
|
|
✅ **Future-proof** - Easy to add new entities or fields |