6.2 KiB
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:
- Type Redundancy: We have multiple types for the same entities (Party vs PartyView, GridWeapon vs GridWeaponItemView)
- Inconsistent Naming: The API uses
objectto refer to nested entities (weapon, character, summon) - Complex Validation: The parseParty function does too much transformation and validation
- 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:
// 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:
// 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:
// 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:
// 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
-
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
-
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
- Add transformation logic to handle
-
Simplify parseParty:
- Remove validation schemas
- Just call the API client's transform method
- Trust the API data structure
-
Update components:
- Use the new single types everywhere
- Access
item.weapon.granblueIdinstead ofitem.object.granblueId - Remove all the
as anycasts
-
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