architecture19 min read

hexagonal-refactoring-analysis

Content rendering notice

This document is displayed in raw format.

# Hexagonal Architecture Refactoring Analysis

**Date**: 2026-01-16 **Analyzed By**: stark-architect **Scope**: NestJS API
modules in `apps/api/src/modules/`

## Executive Summary

The Space Strategy Game API currently follows a traditional layered NestJS
architecture with tight coupling between business logic and infrastructure
concerns. This analysis examines the current architecture patterns across 49
modules and provides a roadmap for gradual migration to hexagonal (ports and
adapters) architecture.

**Key Findings**:

- **High Infrastructure Coupling**: Services directly depend on PrismaService,
  Socket.io, BullMQ
- **Mixed Concerns**: Business logic intertwined with database queries, caching,
  and event emission
- **Limited Testability**: Complex dependencies make unit testing difficult
  without full infrastructure
- **Inconsistent Patterns**: Mix of repository-like and service-heavy approaches

**Recommendation**: Incremental refactoring starting with core game modules
(game, fleet, combat)

---

## Current Architecture Analysis

### 1. Module Structure Pattern

**Current Pattern** (Layered Architecture):

```
modules/
โ”œโ”€โ”€ game/
โ”‚   โ”œโ”€โ”€ game.module.ts           # NestJS module with DI config
โ”‚   โ”œโ”€โ”€ game-state.controller.ts # HTTP/WebSocket endpoints
โ”‚   โ”œโ”€โ”€ turn-processor.service.ts # Business logic + infrastructure
โ”‚   โ”œโ”€โ”€ game-loop.service.ts     # Business logic
โ”‚   โ””โ”€โ”€ turn-timer.service.ts    # Business logic
โ”œโ”€โ”€ fleet/
โ”‚   โ”œโ”€โ”€ fleets.module.ts
โ”‚   โ”œโ”€โ”€ fleets.controller.ts
โ”‚   โ”œโ”€โ”€ fleets.service.ts        # CRUD + business logic
โ”‚   โ””โ”€โ”€ fleet-movement.service.ts # Domain logic + Prisma calls
โ””โ”€โ”€ combat/
    โ”œโ”€โ”€ combat.module.ts
    โ”œโ”€โ”€ combat.controller.ts
    โ”œโ”€โ”€ combat.service.ts         # Orchestration + Prisma
    โ””โ”€โ”€ combat-resolver.service.ts # Domain logic
```

### 2. Infrastructure Coupling Points

#### A. Database Layer (Prisma)

**Coupling Level**: **VERY HIGH** (Direct service dependencies)

**Evidence**:

```typescript
// apps/api/src/modules/fleet/fleets.service.ts (Line 12-16)
@Injectable()
export class FleetsService {
  constructor(private readonly prisma: PrismaService) {}

  async findAll(playerId?: string, gameId?: string) {
    const fleets = await this.prisma.fleet.findMany({
      // Direct Prisma call
      where,
      include: {
        ships: { include: { design: true } },
        currentSystem: true,
        player: { select: { id: true, faction: true } },
      },
    });
  }
}
```

**Problems**:

- Business logic mixed with ORM queries
- Services know about database schema structure
- Include clauses define data shape (should be repository concern)
- Difficult to test without database
- Cannot swap persistence layer without rewriting services

**Current Pattern Across Modules**:

- โœ… **49/49 modules** inject `PrismaService` directly
- โœ… **ALL** services contain raw Prisma queries
- โŒ **ZERO** repository abstractions exist

#### B. Background Jobs (BullMQ)

**Coupling Level**: **MEDIUM** (Service dependencies)

**Evidence**:

```typescript
// apps/api/src/modules/game/turn-processor.service.ts (Line 194-202)
@Injectable()
export class TurnProcessorService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly validator: OrderValidatorService,
    private readonly economy: EconomyService,
    private readonly events: EventsService,
    private readonly fleetMovement: FleetMovementService,
    private readonly combatResolver: CombatResolverService,
    private readonly victory: VictoryService,
    private readonly gamesService: GamesService, // Contains BullMQ calls
  ) {}
}
```

**Infrastructure Leakage**:

```typescript
// apps/api/src/infrastructure/queue/queue.service.ts (Line 46-55)
@Injectable()
export class QueueService {
  constructor(
    @InjectQueue(QUEUE_NAMES.TURN_PROCESSING)
    private readonly turnProcessingQueue: Queue<TurnProcessingJobData>,
    // ... BullMQ infrastructure details exposed to domain
  )
}
```

**Problems**:

- Domain services know about job queue implementation
- Cannot test business logic without mocking BullMQ
- Job data structures (DTOs) mixed with domain models

#### C. WebSocket Events (Socket.io)

**Coupling Level**: **LOW-MEDIUM** (Service abstraction exists)

**Evidence**:

```typescript
// apps/api/src/services/event-sourcing.service.ts (Line 38-68)
async createEvent(gameId: string, eventPayload: GameEventPayload): Promise<void> {
  // Good: Domain event abstraction
  const game = await this.prisma.game.findUnique({ where: { id: gameId } });

  await this.prisma.gameEvent.create({  // Bad: Still coupled to Prisma
    data: {
      gameId,
      eventType: eventPayload.type,
      payload: eventPayload.data as Prisma.InputJsonValue,
    }
  });
}
```

**Better Pattern** (from EventsService):

- Domain events are abstractions (`GameEventType`, `GameEventPayload`)
- But persistence still tightly coupled

#### D. Caching (Redis)

**Coupling Level**: **LOW** (Service abstraction)

**Evidence**:

```typescript
// apps/api/src/services/game-state.service.ts (Line 32-34)
private readonly stateCache = new Map<string, { state: GameState; timestamp: number }>();
private readonly CACHE_TTL = 30000; // In-memory cache for now
```

**Good Pattern**:

- Cache implementation detail hidden
- Could swap for Redis without affecting domain

---

## Detailed Module Analysis

### Module 1: Game Module (`modules/game/`)

**Complexity**: 9/10 (Core game orchestration) **Files**: 12 TypeScript files,
1303 lines **Dependencies**: 8 external services, PrismaService, QueueModule

#### Current Architecture

```
Controller Layer:
  GameStateController
    โ†“
Service Layer (Business Logic + Infrastructure):
  TurnProcessorService (1303 lines!)
    โ”œโ”€โ”€ PrismaService (DB)
    โ”œโ”€โ”€ OrderValidatorService
    โ”œโ”€โ”€ EconomyService
    โ”œโ”€โ”€ EventsService
    โ”œโ”€โ”€ FleetMovementService
    โ”œโ”€โ”€ CombatResolverService
    โ”œโ”€โ”€ VictoryService
    โ””โ”€โ”€ GamesService (BullMQ)

  GameStateService (563 lines)
    โ”œโ”€โ”€ PrismaService (DB)
    โ””โ”€โ”€ EventSourcingService

  EventSourcingService (512 lines)
    โ””โ”€โ”€ PrismaService (DB)
```

#### Coupling Analysis

**Database Coupling** (Lines 194-1302):

```typescript
// TurnProcessorService.processTurn() - 1100 lines of mixed logic
async processTurn(gameId: string): Promise<ProcessedTurn> {
  // Phase 1: Database query (should be repository)
  const orders = await this.prisma.order.findMany({
    where: { gameId, turnNumber, status: OrderStatus.pending },
    orderBy: { createdAt: 'asc' },
    include: { player: { include: { faction: true } } }
  });

  // Phase 2: Validation (domain logic - good)
  const { valid } = await this.validateOrders(gameId, orders);

  // Phase 3: Execution (mixed infrastructure + domain)
  const executionResults = await this.executeOrders(gameId, turnNumber, valid);

  // Phase 4: More Prisma calls (should be repository)
  await this.prisma.order.update({
    where: { id: order.id },
    data: { status: OrderStatus.completed }
  });

  // Phase 5: Economy (calls another service with Prisma)
  await this.economy.processTurnProduction(gameId);
}
```

**N+1 Query Problem** (Lines 454-513):

```typescript
// PERFORMANCE: Load game context ONCE (good optimization!)
private async buildSharedValidationContext(gameId: string): Promise<SharedValidationContext> {
  const game = await this.prisma.game.findUnique({
    where: { id: gameId },
    include: {
      players: {
        include: {
          faction: true,
          fleets: { include: { ships: true } },
          colonies: { include: { buildings: true } }
        }
      }
    }
  });
  // This is a repository pattern trying to emerge!
}
```

**Recommendation for Game Module**:

1. Extract repository abstractions:
   - `OrderRepository` (lines 406-513)
   - `GameRepository` (lines 1195-1244)
   - `SnapshotRepository` (event-sourcing.service.ts)
2. Create domain services:
   - `TurnProcessor` (pure domain logic)
   - `OrderExecutor` (domain logic)
   - `OrderValidator` (already exists, good!)
3. Keep infrastructure adapters:
   - `PrismaOrderRepository implements OrderRepository`
   - `BullMQJobAdapter implements JobScheduler`

---

### Module 2: Fleet Module (`modules/fleet/`)

**Complexity**: 6/10 **Files**: 7 TypeScript files **Dependencies**:
PrismaService

#### Current Architecture

```
FleetsController
  โ†“
FleetsService (309 lines)
  โ”œโ”€โ”€ Direct Prisma CRUD
  โ””โ”€โ”€ enrichFleetData() - UI presentation concern!

FleetMovementService (350 lines)
  โ”œโ”€โ”€ Pure domain logic (movement calculations)
  โ””โ”€โ”€ Direct Prisma persistence
```

#### Issues Identified

**Mixed Concerns** (Lines 21-36):

```typescript
// FleetsService has PRESENTATION LOGIC!
private enrichFleetData(fleet: any) {
  const shipsWithMaxHull = fleet.ships?.map((ship: any) => ({
    ...ship,
    maxHull: (ship.design?.baseStats as any)?.hull || 100,
  })) || [];

  const totalMaxHull = shipsWithMaxHull.reduce((sum, ship) => sum + ship.maxHull, 0);

  return { ...fleet, ships: shipsWithMaxHull, totalMaxHull };
}
// This should be in a DTO mapper or presentation layer!
```

**Good Domain Logic** (FleetMovementService lines 284-327):

```typescript
// GOOD: Pure calculation functions (no infrastructure)
private calculateFleetSpeed(ships: any[]): number {
  if (ships.length === 0) return FLEET.DEFAULT_SPEED;
  const speeds = ships.map(ship => ship.design.baseStats.speed || FLEET.DEFAULT_SPEED);
  return Math.min(...speeds, FLEET.DEFAULT_SPEED);
}

public calculateFuelConsumption(distance: number, shipCount: number): number {
  const baseCost = Math.ceil(distance / FUEL.BASE_COST_PER_DISTANCE);
  const scaledCost = baseCost * Math.sqrt(shipCount);
  return Math.ceil(scaledCost);
}
// These are perfect domain service methods!
```

**Recommendation for Fleet Module**:

1. Separate concerns:
   - **Domain**: FleetMovementCalculator (pure functions)
   - **Application**: FleetService (orchestration)
   - **Infrastructure**: FleetRepository (Prisma)
   - **Presentation**: FleetDtoMapper (enrichFleetData)
2. Move validation to domain layer
3. Extract movement calculations to `@space-strategy/game-engine`

---

### Module 3: Combat Module (`modules/combat/`)

**Complexity**: 7/10 **Files**: 10 TypeScript files **Dependencies**:
PrismaService, game-engine package

#### Current Architecture

```
CombatController
  โ†“
CombatService (259 lines)
  โ”œโ”€โ”€ PrismaService (DB)
  โ””โ”€โ”€ CombatResolver (from game-engine - GOOD!)

CombatResolverService (separate orchestration)
  โ””โ”€โ”€ More Prisma calls
```

#### Good Pattern Found

**Domain Logic Extraction** (Lines 73-76):

```typescript
// GOOD: Using game-engine package for pure domain logic
import { CombatResolver } from '@space-strategy/game-engine';

const engineFleet1 = this.mapToEngineFleet(fleet1);
const engineFleet2 = this.mapToEngineFleet(fleet2);
const resolver = new CombatResolver(engineFleet1, engineFleet2);
const result = resolver.resolveBattle(); // Pure function!
```

**Infrastructure Still Coupled** (Lines 204-245):

```typescript
// BAD: Persistence logic mixed in
private async processCombatResult(result: any, fleet1: any, fleet2: any) {
  const destroyedShips = new Set<string>();

  for (const round of result.rounds) {
    round.attackerCasualties.forEach(id => destroyedShips.add(id));
  }

  // Direct Prisma call
  await this.prisma.ship.updateMany({
    where: { id: { in: Array.from(destroyedShips) } },
    data: { status: 'destroyed', destroyedAt: new Date() }
  });

  // More Prisma
  await this.prisma.gameEvent.create({
    data: { /* ... */ }
  });
}
```

**Recommendation for Combat Module**:

1. Keep game-engine integration (already good)
2. Extract `CombatResultRepository`
3. Create `CombatOrchestrator` application service
4. Remove Prisma from CombatService

---

## Hexagonal Architecture Proposal

### Core Concepts

**Hexagonal Architecture** (Ports and Adapters):

```
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                        Presentation                          โ”‚
โ”‚  (Controllers, WebSocket Gateways, CLI, GraphQL Resolvers)  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ†“ uses
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    Application Layer                         โ”‚
โ”‚         (Use Cases, DTOs, Orchestration Services)           โ”‚
โ”‚                                                              โ”‚
โ”‚    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”         โ”‚
โ”‚    โ”‚         Domain Layer (Ports)                 โ”‚         โ”‚
โ”‚    โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚         โ”‚
โ”‚    โ”‚  โ”‚  Pure Business Logic                   โ”‚  โ”‚         โ”‚
โ”‚    โ”‚  โ”‚  - Entities (Game, Fleet, Order)       โ”‚  โ”‚         โ”‚
โ”‚    โ”‚  โ”‚  - Value Objects (Position, Resources) โ”‚  โ”‚         โ”‚
โ”‚    โ”‚  โ”‚  - Domain Services (Movement, Combat)  โ”‚  โ”‚         โ”‚
โ”‚    โ”‚  โ”‚  - Domain Events                       โ”‚  โ”‚         โ”‚
โ”‚    โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚         โ”‚
โ”‚    โ”‚                                              โ”‚         โ”‚
โ”‚    โ”‚  Repository Ports (Interfaces):             โ”‚         โ”‚
โ”‚    โ”‚  - IGameRepository                          โ”‚         โ”‚
โ”‚    โ”‚  - IFleetRepository                         โ”‚         โ”‚
โ”‚    โ”‚  - IOrderRepository                         โ”‚         โ”‚
โ”‚    โ”‚                                              โ”‚         โ”‚
โ”‚    โ”‚  Service Ports (Interfaces):                โ”‚         โ”‚
โ”‚    โ”‚  - IEventPublisher                          โ”‚         โ”‚
โ”‚    โ”‚  - IJobScheduler                            โ”‚         โ”‚
โ”‚    โ”‚  - ICacheService                            โ”‚         โ”‚
โ”‚    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ†“ implements
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚              Infrastructure Layer (Adapters)                 โ”‚
โ”‚                                                              โ”‚
โ”‚  Database Adapters:          Messaging Adapters:            โ”‚
โ”‚  - PrismaGameRepository      - SocketIoEventPublisher       โ”‚
โ”‚  - PrismaFleetRepository     - BullMQJobScheduler           โ”‚
โ”‚                                                              โ”‚
โ”‚  External Services:          Storage:                       โ”‚
โ”‚  - RedisCache                - S3ReplayStorage              โ”‚
โ”‚  - HttpAiClient              - LocalFileStorage             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
```

### Proposed Directory Structure

```
apps/api/src/
โ”œโ”€โ”€ domain/                           # Domain Layer (Pure Business Logic)
โ”‚   โ”œโ”€โ”€ game/
โ”‚   โ”‚   โ”œโ”€โ”€ entities/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ game.entity.ts        # Pure domain entity
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ turn.entity.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ order.entity.ts
โ”‚   โ”‚   โ”œโ”€โ”€ value-objects/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ turn-number.vo.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ game-status.vo.ts
โ”‚   โ”‚   โ”œโ”€โ”€ services/                 # Domain services (pure logic)
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ turn-processor.domain-service.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ order-validator.domain-service.ts
โ”‚   โ”‚   โ”œโ”€โ”€ events/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ turn-completed.event.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ order-executed.event.ts
โ”‚   โ”‚   โ””โ”€โ”€ repositories/             # Repository ports (interfaces)
โ”‚   โ”‚       โ”œโ”€โ”€ game.repository.interface.ts
โ”‚   โ”‚       โ”œโ”€โ”€ order.repository.interface.ts
โ”‚   โ”‚       โ””โ”€โ”€ snapshot.repository.interface.ts
โ”‚   โ”œโ”€โ”€ fleet/
โ”‚   โ”‚   โ”œโ”€โ”€ entities/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ fleet.entity.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ ship.entity.ts
โ”‚   โ”‚   โ”œโ”€โ”€ value-objects/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ position.vo.ts
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ velocity.vo.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ fuel.vo.ts
โ”‚   โ”‚   โ”œโ”€โ”€ services/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ movement-calculator.service.ts  # Pure calculations
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ fuel-calculator.service.ts
โ”‚   โ”‚   โ””โ”€โ”€ repositories/
โ”‚   โ”‚       โ””โ”€โ”€ fleet.repository.interface.ts
โ”‚   โ””โ”€โ”€ combat/
โ”‚       โ”œโ”€โ”€ entities/
โ”‚       โ”‚   โ””โ”€โ”€ battle.entity.ts
โ”‚       โ”œโ”€โ”€ services/
โ”‚       โ”‚   โ””โ”€โ”€ combat-resolver.service.ts  # Already exists in game-engine!
โ”‚       โ””โ”€โ”€ repositories/
โ”‚           โ””โ”€โ”€ combat.repository.interface.ts
โ”‚
โ”œโ”€โ”€ application/                      # Application Layer (Use Cases)
โ”‚   โ”œโ”€โ”€ game/
โ”‚   โ”‚   โ”œโ”€โ”€ use-cases/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ process-turn.use-case.ts        # Orchestration
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ submit-order.use-case.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ advance-turn.use-case.ts
โ”‚   โ”‚   โ”œโ”€โ”€ dto/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ process-turn.dto.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ submit-order.dto.ts
โ”‚   โ”‚   โ””โ”€โ”€ mappers/
โ”‚   โ”‚       โ””โ”€โ”€ game.mapper.ts                  # Domain <-> DTO
โ”‚   โ”œโ”€โ”€ fleet/
โ”‚   โ”‚   โ”œโ”€โ”€ use-cases/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ move-fleet.use-case.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ create-fleet.use-case.ts
โ”‚   โ”‚   โ””โ”€โ”€ dto/
โ”‚   โ”‚       โ””โ”€โ”€ move-fleet.dto.ts
โ”‚   โ””โ”€โ”€ ports/                        # Application ports (interfaces)
โ”‚       โ”œโ”€โ”€ event-publisher.interface.ts
โ”‚       โ”œโ”€โ”€ job-scheduler.interface.ts
โ”‚       โ””โ”€โ”€ cache.interface.ts
โ”‚
โ”œโ”€โ”€ infrastructure/                   # Infrastructure Layer (Adapters)
โ”‚   โ”œโ”€โ”€ persistence/
โ”‚   โ”‚   โ”œโ”€โ”€ prisma/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ repositories/
โ”‚   โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ prisma-game.repository.ts   # implements IGameRepository
โ”‚   โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ prisma-fleet.repository.ts  # implements IFleetRepository
โ”‚   โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ prisma-order.repository.ts  # implements IOrderRepository
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ prisma.service.ts
โ”‚   โ”‚   โ””โ”€โ”€ in-memory/                # For testing
โ”‚   โ”‚       โ””โ”€โ”€ in-memory-game.repository.ts
โ”‚   โ”œโ”€โ”€ messaging/
โ”‚   โ”‚   โ”œโ”€โ”€ bullmq/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ bullmq-job-scheduler.adapter.ts # implements IJobScheduler
โ”‚   โ”‚   โ””โ”€โ”€ socketio/
โ”‚   โ”‚       โ””โ”€โ”€ socketio-event-publisher.adapter.ts # implements IEventPublisher
โ”‚   โ”œโ”€โ”€ cache/
โ”‚   โ”‚   โ”œโ”€โ”€ redis-cache.adapter.ts    # implements ICacheService
โ”‚   โ”‚   โ””โ”€โ”€ in-memory-cache.adapter.ts
โ”‚   โ””โ”€โ”€ external/
โ”‚       โ””โ”€โ”€ http-ai-client.adapter.ts
โ”‚
โ”œโ”€โ”€ presentation/                     # Presentation Layer
โ”‚   โ”œโ”€โ”€ http/
โ”‚   โ”‚   โ”œโ”€โ”€ controllers/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ game.controller.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ fleet.controller.ts
โ”‚   โ”‚   โ””โ”€โ”€ dto/                      # API DTOs
โ”‚   โ”‚       โ””โ”€โ”€ fleet-response.dto.ts
โ”‚   โ””โ”€โ”€ websocket/
โ”‚       โ””โ”€โ”€ gateways/
โ”‚           โ””โ”€โ”€ game.gateway.ts
โ”‚
โ””โ”€โ”€ modules/                          # NestJS Modules (DI Configuration)
    โ”œโ”€โ”€ game.module.ts
    โ”œโ”€โ”€ fleet.module.ts
    โ””โ”€โ”€ combat.module.ts
```

---

## Migration Strategy

### Phase 1: Extract Repository Abstractions (2 weeks)

**Goal**: Create repository interfaces and Prisma implementations

**Modules to Target**:

1. **Game Module** (highest priority)
   - `IGameRepository`
   - `IOrderRepository`
   - `ISnapshotRepository`

2. **Fleet Module**
   - `IFleetRepository`
   - `IShipRepository`

3. **Combat Module**
   - `ICombatRepository`

**Example Implementation**:

```typescript
// domain/game/repositories/game.repository.interface.ts
export interface IGameRepository {
  findById(id: string): Promise<Game>;
  findWithPlayers(id: string): Promise<Game>;
  updateStatus(id: string, status: GameStatus): Promise<void>;
  advanceTurn(id: string): Promise<void>;
}

// infrastructure/persistence/prisma/repositories/prisma-game.repository.ts
@Injectable()
export class PrismaGameRepository implements IGameRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findById(id: string): Promise<Game> {
    const data = await this.prisma.game.findUnique({ where: { id } });
    return GameMapper.toDomain(data); // Map Prisma model to domain entity
  }

  async findWithPlayers(id: string): Promise<Game> {
    const data = await this.prisma.game.findUnique({
      where: { id },
      include: { players: { include: { faction: true } } },
    });
    return GameMapper.toDomain(data);
  }

  // ... other methods
}

// application/game/use-cases/process-turn.use-case.ts
@Injectable()
export class ProcessTurnUseCase {
  constructor(
    @Inject('IGameRepository')
    private readonly gameRepo: IGameRepository, // Port, not implementation!
    @Inject('IOrderRepository')
    private readonly orderRepo: IOrderRepository,
  ) {}

  async execute(gameId: string): Promise<TurnResult> {
    const game = await this.gameRepo.findWithPlayers(gameId);
    const orders = await this.orderRepo.findPendingByGameAndTurn(
      gameId,
      game.currentTurn,
    );

    // Pure domain logic here
    const result = game.processTurn(orders);

    await this.gameRepo.advanceTurn(gameId);
    return result;
  }
}
```

**NestJS Module Configuration**:

```typescript
// modules/game.module.ts
@Module({
  providers: [
    // Repository implementations
    {
      provide: 'IGameRepository',
      useClass: PrismaGameRepository,
    },
    {
      provide: 'IOrderRepository',
      useClass: PrismaOrderRepository,
    },

    // Use cases
    ProcessTurnUseCase,
    SubmitOrderUseCase,

    // Keep existing for backwards compatibility
    TurnProcessorService, // Will delegate to use cases
  ],
  exports: ['IGameRepository', 'IOrderRepository', ProcessTurnUseCase],
})
export class GameModule {}
```

### Phase 2: Extract Domain Entities (2 weeks)

**Goal**: Move business logic from services into domain entities

**Current Problem**:

```typescript
// ANEMIC DOMAIN MODEL (current)
class Fleet {
  id: string;
  positionX: Decimal;
  positionY: Decimal;
  // Just data, no behavior
}

// Business logic in SERVICE layer (wrong place)
class FleetMovementService {
  async moveFleet(fleetId: string, targetX: number, targetY: number) {
    const fleet = await this.prisma.fleet.findUnique({
      where: { id: fleetId },
    });

    // LOGIC SHOULD BE IN FLEET ENTITY
    const distance = Math.sqrt(
      (targetX - fleet.positionX) ** 2 + (targetY - fleet.positionY) ** 2,
    );
    const speed = this.calculateFleetSpeed(fleet.ships);
    // ...
  }
}
```

**Proposed Solution**:

```typescript
// RICH DOMAIN MODEL
export class Fleet extends AggregateRoot {
  private constructor(
    public readonly id: FleetId,
    private position: Position,
    private velocity: Velocity,
    private ships: Ship[],
    private fuel: Fuel,
  ) {
    super();
  }

  // Factory method
  static create(data: CreateFleetData): Fleet {
    const fleet = new Fleet(
      FleetId.create(data.id),
      Position.create(data.positionX, data.positionY),
      Velocity.zero(),
      data.ships.map(Ship.create),
      Fuel.create(data.fuel),
    );

    fleet.addDomainEvent(new FleetCreatedEvent(fleet.id));
    return fleet;
  }

  // BUSINESS LOGIC IN ENTITY (correct place)
  moveTo(target: Position, constants: FleetConstants): MovementResult {
    // Validate
    if (this.ships.length === 0) {
      throw new EmptyFleetError(this.id);
    }

    // Calculate movement
    const distance = this.position.distanceTo(target);
    const speed = this.calculateSpeed();
    const maxDistance = speed * constants.DISTANCE_PER_SPEED;

    // Apply movement
    const actualDistance = Math.min(distance, maxDistance);
    const arrived = distance <= maxDistance;

    const newPosition = this.position.moveTowards(target, actualDistance);
    const fuelConsumed = this.calculateFuelConsumption(actualDistance);

    if (!this.fuel.canAfford(fuelConsumed)) {
      throw new InsufficientFuelError(this.id, fuelConsumed, this.fuel.amount);
    }

    // Update state
    this.position = newPosition;
    this.fuel = this.fuel.subtract(fuelConsumed);

    // Emit domain event
    this.addDomainEvent(
      new FleetMovedEvent(this.id, this.position, fuelConsumed, arrived),
    );

    return { arrived, fuelConsumed, newPosition };
  }

  private calculateSpeed(): number {
    const speeds = this.ships.map(ship => ship.speed);
    return Math.min(...speeds);
  }

  private calculateFuelConsumption(distance: number): Fuel {
    const baseCost = Math.ceil(distance / FUEL.BASE_COST_PER_DISTANCE);
    const scaledCost = baseCost * Math.sqrt(this.ships.length);
    return Fuel.create(Math.ceil(scaledCost));
  }
}

// APPLICATION LAYER (thin orchestration)
@Injectable()
export class MoveFleetUseCase {
  constructor(
    @Inject('IFleetRepository')
    private readonly fleetRepo: IFleetRepository,
    @Inject('IEventPublisher')
    private readonly eventPublisher: IEventPublisher,
  ) {}

  async execute(command: MoveFleetCommand): Promise<void> {
    // Load aggregate
    const fleet = await this.fleetRepo.findById(command.fleetId);

    // Execute domain logic (in entity)
    const target = Position.create(command.targetX, command.targetY);
    fleet.moveTo(target, FLEET_CONSTANTS);

    // Save aggregate
    await this.fleetRepo.save(fleet);

    // Publish events
    await this.eventPublisher.publishMany(fleet.domainEvents);
  }
}
```

### Phase 3: Extract Application Use Cases (2 weeks)

**Goal**: Create explicit use case classes for each operation

**Benefits**:

- Clear command/query separation
- Explicit dependencies (easy to test)
- Transaction boundaries well-defined
- Single Responsibility Principle

**Example**:

```typescript
// application/game/use-cases/submit-order.use-case.ts
export class SubmitOrderCommand {
  constructor(
    public readonly gameId: string,
    public readonly playerId: string,
    public readonly orderType: OrderType,
    public readonly sourceId: string,
    public readonly parameters: OrderParameters,
  ) {}
}

@Injectable()
export class SubmitOrderUseCase {
  constructor(
    @Inject('IGameRepository') private readonly gameRepo: IGameRepository,
    @Inject('IOrderRepository') private readonly orderRepo: IOrderRepository,
    @Inject('IPlayerRepository') private readonly playerRepo: IPlayerRepository,
    private readonly orderValidator: OrderValidationService, // Domain service
  ) {}

  async execute(command: SubmitOrderCommand): Promise<Order> {
    // Load aggregates
    const game = await this.gameRepo.findById(command.gameId);
    const player = await this.playerRepo.findById(command.playerId);

    // Validate (domain service)
    const validationResult = this.orderValidator.validate(
      game,
      player,
      command,
    );
    if (!validationResult.valid) {
      throw new InvalidOrderError(validationResult.errors);
    }

    // Create order (domain entity)
    const order = Order.create({
      gameId: command.gameId,
      playerId: command.playerId,
      turnNumber: game.currentTurn,
      orderType: command.orderType,
      sourceId: command.sourceId,
      parameters: command.parameters,
    });

    // Persist
    await this.orderRepo.save(order);

    return order;
  }
}
```

### Phase 4: Implement Service Ports (1 week)

**Goal**: Abstract infrastructure services behind interfaces

**Example**:

```typescript
// application/ports/job-scheduler.interface.ts
export interface IJobScheduler {
  scheduleJob<T>(
    jobName: string,
    data: T,
    options?: JobOptions,
  ): Promise<JobId>;
  scheduleBulk<T>(jobs: JobDefinition<T>[]): Promise<JobId[]>;
}

// infrastructure/messaging/bullmq/bullmq-job-scheduler.adapter.ts
@Injectable()
export class BullMQJobScheduler implements IJobScheduler {
  constructor(
    @InjectQueue(QUEUE_NAMES.TURN_PROCESSING)
    private readonly turnQueue: Queue,
  ) {}

  async scheduleJob<T>(
    jobName: string,
    data: T,
    options?: JobOptions,
  ): Promise<JobId> {
    const job = await this.turnQueue.add(
      jobName,
      data,
      this.mapOptions(options),
    );
    return JobId.create(job.id);
  }
}
```

### Phase 5: Migrate Controllers to Use Cases (1 week)

**Goal**: Thin controller layer that delegates to use cases

**Example**:

```typescript
// presentation/http/controllers/fleet.controller.ts
@Controller('fleets')
export class FleetsController {
  constructor(
    private readonly createFleet: CreateFleetUseCase,
    private readonly moveFleet: MoveFleetUseCase,
    private readonly getFleet: GetFleetQuery, // CQRS query
  ) {}

  @Post()
  async create(
    @CurrentUser('playerId') playerId: string,
    @Body() dto: CreateFleetDto,
  ) {
    const command = CreateFleetCommand.fromDto(playerId, dto);
    const fleet = await this.createFleet.execute(command);
    return FleetResponseMapper.toDto(fleet);
  }

  @Post(':id/move')
  async move(@Param('id') id: string, @Body() dto: MoveFleetDto) {
    const command = new MoveFleetCommand(id, dto.targetX, dto.targetY);
    await this.moveFleet.execute(command);
    return { success: true };
  }
}
```

---

## Testing Strategy

### Current State (Difficult to Test)

```typescript
// Hard to test: requires Prisma, database, and all dependencies
describe('TurnProcessorService', () => {
  it('should process turn', async () => {
    // Need to mock: Prisma, OrderValidator, Economy, Events, Fleet, Combat, Victory, Games
    // Need to seed database with test data
    // Need to run migrations
    // Slow, brittle, integration test masquerading as unit test
  });
});
```

### Hexagonal State (Easy to Test)

```typescript
// Easy to test: pure domain logic
describe('Fleet', () => {
  it('should move towards target', () => {
    // Arrange
    const fleet = Fleet.create({
      position: Position.create(0, 0),
      ships: [Ship.create({ speed: 10 })],
      fuel: Fuel.create(100),
    });

    // Act
    const result = fleet.moveTo(Position.create(50, 0), FLEET_CONSTANTS);

    // Assert
    expect(result.arrived).toBe(false);
    expect(fleet.position.x).toBe(10); // Moved 10 units
    expect(fleet.fuel.amount).toBeLessThan(100);
  });
});

// Application layer: mock repositories
describe('MoveFleetUseCase', () => {
  it('should load fleet, execute movement, and save', async () => {
    // Arrange
    const mockRepo = createMock<IFleetRepository>();
    const fleet = Fleet.create({
      /* ... */
    });
    mockRepo.findById.mockResolvedValue(fleet);

    const useCase = new MoveFleetUseCase(mockRepo, mockEventPublisher);

    // Act
    await useCase.execute(new MoveFleetCommand('fleet-1', 50, 0));

    // Assert
    expect(mockRepo.findById).toHaveBeenCalledWith('fleet-1');
    expect(mockRepo.save).toHaveBeenCalledWith(fleet);
  });
});
```

---

## Benefits of Hexagonal Architecture

### 1. Testability

**Current**: Integration tests disguised as unit tests **Hexagonal**: True unit
tests for domain, mockable ports for application layer

### 2. Flexibility

**Current**: Tightly coupled to Prisma, BullMQ, Socket.io **Hexagonal**: Swap
persistence (Prisma โ†’ TypeORM), queue (BullMQ โ†’ RabbitMQ), events (Socket.io โ†’
Server-Sent Events)

### 3. Maintainability

**Current**: 1303-line TurnProcessorService mixing concerns **Hexagonal**:
Small, focused classes with single responsibilities

### 4. Domain-Driven Design

**Current**: Anemic domain models (just data) **Hexagonal**: Rich domain models
(data + behavior)

### 5. Clear Boundaries

**Current**: Unclear where business logic lives (services? repositories?
controllers?) **Hexagonal**: Explicit layers with well-defined interfaces

---

## Risks and Mitigation

### Risk 1: Over-Engineering

**Concern**: Adding complexity without clear benefit

**Mitigation**:

- Start with core modules (game, fleet, combat)
- Measure before/after (test coverage, coupling metrics)
- Keep existing code working (Strangler Fig pattern)
- Don't refactor modules that work well already

### Risk 2: Breaking Changes

**Concern**: Refactoring breaks existing functionality

**Mitigation**:

- Maintain backwards compatibility during migration
- Adapters can wrap old services temporarily
- Comprehensive test suite before refactoring
- Feature flags for new implementations

### Risk 3: Team Learning Curve

**Concern**: Team unfamiliar with hexagonal architecture

**Mitigation**:

- Pair programming on first modules
- Document patterns in ARCHITECTURE.md
- Code review for architectural adherence
- Gradual migration (not big bang rewrite)

### Risk 4: Performance Overhead

**Concern**: Additional abstraction layers may slow performance

**Mitigation**:

- Profile before/after
- Keep hot paths simple (e.g., read queries can bypass domain)
- Use CQRS for read-heavy operations
- Lazy loading where appropriate

---

## Success Metrics

### Code Quality Metrics

**Before Refactoring**:

- Service class sizes: 300-1303 lines
- Cyclomatic complexity: 15-40 per method
- Coupling: 8-12 dependencies per service
- Test coverage: 85% (mostly integration tests)

**After Refactoring (Target)**:

- Service class sizes: <150 lines
- Cyclomatic complexity: <10 per method
- Coupling: 2-4 dependencies per use case
- Test coverage: 90% (60% unit, 30% integration)

### Development Velocity

- Time to add new feature: Currently 2-3 days โ†’ Target 1-2 days
- Time to fix bug: Currently 3-4 hours โ†’ Target 1-2 hours
- Time to write tests: Currently 30% of feature time โ†’ Target 20%

---

## Recommended Prioritization

### High Priority (Immediate Benefits)

1. **Game Module** (TurnProcessorService refactor)
   - Complexity: 9/10
   - Pain: High (1303 lines, difficult to maintain)
   - Benefit: Huge (core game logic clarity)
   - Effort: 2 weeks

2. **Fleet Module** (Movement logic extraction)
   - Complexity: 6/10
   - Pain: Medium (mixed concerns)
   - Benefit: High (testability, reusability)
   - Effort: 1 week

3. **Combat Module** (Already partially hexagonal)
   - Complexity: 7/10
   - Pain: Low (already uses game-engine)
   - Benefit: Medium (consistency)
   - Effort: 1 week

### Medium Priority (Incremental Improvements)

4. **Economy Module**
5. **Technology Module**
6. **Diplomacy Module**

### Low Priority (Defer Until Needed)

7-49. Other modules (refactor only when changing)

---

## Conclusion

The Space Strategy Game API exhibits classic symptoms of a growing monolith:

- Infrastructure leakage into business logic
- Services with mixed responsibilities
- Difficulty testing and maintaining core features

**Hexagonal architecture provides**:

- Clear separation of concerns
- Testable domain logic
- Flexibility to change infrastructure
- Scalable codebase structure

**Recommended approach**:

1. **Start small**: Game module repository extraction
2. **Prove value**: Measure testability improvements
3. **Iterate**: Expand to Fleet and Combat modules
4. **Document**: Create architecture decision records
5. **Train**: Ensure team understands patterns

**Timeline**: 8 weeks for Phase 1-5 (core modules only)

**Next Steps**:

1. Review this analysis with team
2. Create detailed implementation plan for Game module
3. Set up metrics tracking (complexity, coverage, velocity)
4. Begin Phase 1 with IGameRepository extraction

Related Documentation

๐Ÿ—๏ธ
architecture

adr-001-server-authoritative-architecture

Read more โ†’
๐Ÿ—๏ธ
architecture

adr-002-event-driven-game-state

Read more โ†’
๐Ÿ—๏ธ
architecture

adr-003-monorepo-structure

Read more โ†’

Was this page helpful?

Help us improve our documentation