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
๐๏ธ
๐๏ธ
๐๏ธ
Was this page helpful?
Help us improve our documentation