Skip to content

Clean Architecture

O CSGOFlip implementa Clean Architecture para garantir código organizado, testável e manutenível em um sistema com 17 módulos e 95+ use cases.

O que é Clean Architecture?

Clean Architecture é um padrão de design criado por Robert C. Martin (Uncle Bob) que organiza o código em camadas concêntricas, onde dependências apontam apenas para dentro.

┌───────────────────────────────────────────────────────────────┐
│                                                               │
│    ┌───────────────────────────────────────────────────┐     │
│    │                                                   │     │
│    │    ┌───────────────────────────────────────┐     │     │
│    │    │                                       │     │     │
│    │    │    ┌───────────────────────────┐     │     │     │
│    │    │    │                           │     │     │     │
│    │    │    │        DOMAIN             │     │     │     │
│    │    │    │    (Entities, Rules)      │     │     │     │
│    │    │    │                           │     │     │     │
│    │    │    └───────────────────────────┘     │     │     │
│    │    │                                       │     │     │
│    │    │           APPLICATION                 │     │     │
│    │    │         (Use Cases)                   │     │     │
│    │    │                                       │     │     │
│    │    └───────────────────────────────────────┘     │     │
│    │                                                   │     │
│    │              INFRASTRUCTURE                       │     │
│    │        (DB, Cache, External APIs)                 │     │
│    │                                                   │     │
│    └───────────────────────────────────────────────────┘     │
│                                                               │
│                      PRESENTATION                             │
│                (Controllers, WebSocket)                       │
│                                                               │
└───────────────────────────────────────────────────────────────┘

        Dependências apontam para DENTRO →

Por que Clean Architecture no CSGOFlip?

1. Testabilidade

Use cases podem ser testados sem banco de dados, HTTP ou Redis:

typescript
// Teste unitário do OpenCaseUseCase
describe('OpenCaseUseCase', () => {
  let useCase: OpenCaseUseCase;
  let mockCaseRepo: jest.Mocked<ICaseRepository>;
  let mockTransactionService: jest.Mocked<TransactionService>;

  beforeEach(() => {
    // Cria mocks das dependências
    mockCaseRepo = {
      findById: jest.fn(),
      findItemsWithProbabilities: jest.fn(),
    };
    mockTransactionService = {
      debit: jest.fn(),
      getUserBalance: jest.fn(),
    };

    // Injeta mocks no use case
    useCase = new OpenCaseUseCase(mockCaseRepo, mockTransactionService);
  });

  it('should open case and return item', async () => {
    // Arrange
    mockCaseRepo.findById.mockResolvedValue(mockCase);
    mockTransactionService.getUserBalance.mockResolvedValue(10000n);

    // Act
    const result = await useCase.execute(userId, caseId);

    // Assert
    expect(result.item).toBeDefined();
    expect(mockTransactionService.debit).toHaveBeenCalled();
  });

  it('should throw if insufficient balance', async () => {
    mockTransactionService.getUserBalance.mockResolvedValue(100n);

    await expect(useCase.execute(userId, caseId))
      .rejects.toThrow(InsufficientBalanceError);
  });
});

2. Independência de Framework

A lógica de negócio não conhece NestJS:

typescript
// ❌ ERRADO - Use case conhece framework
@Injectable()
export class OpenCaseUseCase {
  constructor(
    @Inject(REQUEST) private request: Request, // Dependência de HTTP!
  ) {}
}

// ✅ CORRETO - Use case puro
@Injectable()
export class OpenCaseUseCase {
  constructor(
    @Inject(CASE_REPOSITORY)
    private caseRepository: ICaseRepository, // Interface, não implementação
  ) {}

  async execute(userId: bigint, caseId: bigint): Promise<OpenCaseResult> {
    // Lógica de negócio pura
  }
}

3. Substituibilidade

Podemos trocar implementações sem alterar lógica:

typescript
// Trocar de PostgreSQL para MySQL? Só criar novo repositório
@Module({
  providers: [
    {
      provide: CASE_REPOSITORY,
      useClass: process.env.DB === 'mysql' 
        ? MySQLCaseRepository 
        : PostgresCaseRepository,
    },
  ],
})

4. Organização em 95+ Use Cases

Cada operação tem seu próprio arquivo:

src/application/use-cases/
├── case-opening/
│   ├── open-case.use-case.ts          # Abrir uma caixa
│   ├── open-multiple-cases.use-case.ts # Abrir várias
│   ├── verify-opening.use-case.ts     # Verificar resultado
│   └── get-history.use-case.ts        # Histórico

├── battle/
│   ├── create-battle.use-case.ts      # Criar batalha
│   ├── join-battle.use-case.ts        # Entrar em batalha
│   ├── execute-battle.use-case.ts     # Executar rodadas
│   └── cancel-battle.use-case.ts      # Cancelar

Camadas no CSGOFlip

Camada de Domínio (src/domain/)

A camada mais interna. Contém:

  • Entities: Tipos que representam objetos do domínio
  • Repository Interfaces: Contratos de acesso a dados
typescript
// src/domain/entities/user.entity.ts
export interface User {
  id: bigint;
  steamId: string;
  username: string;
  avatarUrl: string;
  balanceCents: bigint; // CACHE apenas!
  role: UserRole;
  status: UserStatus;
  createdAt: Date;
}

// src/domain/repositories/user.repository.interface.ts
export interface IUserRepository {
  findById(id: bigint): Promise<User | null>;
  findBySteamId(steamId: string): Promise<User | null>;
  create(data: CreateUserData): Promise<User>;
  update(id: bigint, data: UpdateUserData): Promise<User>;
}

Por que interfaces?

Interfaces permitem injetar mocks nos testes e trocar implementações sem alterar código.

Camada de Aplicação (src/application/)

Contém a lógica de negócio:

  • Use Cases: Uma classe por operação
  • Services: Lógica compartilhada entre use cases
  • DTOs: Estruturas de entrada/saída
typescript
// src/application/use-cases/case-opening/open-case.use-case.ts
@Injectable()
export class OpenCaseUseCase {
  constructor(
    @Inject(CASE_REPOSITORY)
    private readonly caseRepository: ICaseRepository,
    @Inject(CASE_OPENING_REPOSITORY)
    private readonly openingRepository: ICaseOpeningRepository,
    private readonly transactionService: TransactionService,
    private readonly provablyFairService: ProvablyFairService,
    private readonly flipService: FlipService,
  ) {}

  async execute(
    userId: bigint,
    caseId: bigint,
    clientSeed?: string,
  ): Promise<CaseOpeningResult> {
    // 1. Buscar caixa
    const caseData = await this.caseRepository.findById(caseId);
    if (!caseData) {
      throw new CaseNotFoundError(caseId);
    }

    // 2. Verificar saldo
    const balance = await this.transactionService.getUserBalance(userId);
    if (balance < caseData.priceCents) {
      throw new InsufficientBalanceError();
    }

    // 3. Gerar seeds Provably Fair
    const serverSeed = this.provablyFairService.generateServerSeed();
    const serverSeedHash = this.provablyFairService.hashSeed(serverSeed);
    const finalClientSeed = clientSeed || this.provablyFairService.generateClientSeed();

    // 4. Calcular roll
    const nonce = await this.openingRepository.getNextNonce(userId);
    const roll = this.provablyFairService.calculateRoll(
      serverSeed,
      finalClientSeed,
      nonce,
    );

    // 5. Verificar se FLIP ativa
    const isFlip = this.flipService.shouldTriggerFlip(roll);

    // 6. Selecionar item
    let wonItem: Item;
    let flipData: FlipData | null = null;

    if (isFlip) {
      // Segunda roleta com itens raros
      flipData = await this.handleFlip(caseId, serverSeed, finalClientSeed, nonce);
      wonItem = flipData.wonItem;
    } else {
      // Roleta normal
      wonItem = await this.selectItemFromRoll(caseId, roll);
    }

    // 7. Debitar saldo
    await this.transactionService.debit(
      userId,
      caseData.priceCents,
      TransactionReason.CASE_OPENING,
    );

    // 8. Criar registro de abertura
    const opening = await this.openingRepository.create({
      userId,
      caseId,
      itemId: wonItem.id,
      serverSeed,
      serverSeedHash,
      clientSeed: finalClientSeed,
      nonce,
      roll,
      isFlip,
      ...flipData,
    });

    // 9. Adicionar item ao inventário
    await this.inventoryRepository.addItem(userId, wonItem.id);

    return {
      id: opening.id,
      item: wonItem,
      roll,
      isFlip,
      serverSeedHash,
    };
  }
}

Camada de Infraestrutura (src/infrastructure/)

Implementa detalhes técnicos:

  • Repositories: Implementação com Prisma
  • External APIs: Steam, pagamentos, S3
  • Cache: Redis
  • WebSocket: Socket.io
typescript
// src/infrastructure/database/repositories/case.repository.ts
@Injectable()
export class CaseRepository implements ICaseRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findById(id: bigint): Promise<Case | null> {
    const caseData = await this.prisma.case.findUnique({
      where: { id },
      include: {
        items: {
          include: { item: true },
        },
      },
    });

    return caseData ? this.mapToEntity(caseData) : null;
  }

  async findAll(filters: CaseFilters): Promise<Case[]> {
    const cases = await this.prisma.case.findMany({
      where: {
        status: filters.status,
        category: filters.category,
      },
      orderBy: { order: 'asc' },
    });

    return cases.map(this.mapToEntity);
  }

  private mapToEntity(data: PrismaCase): Case {
    return {
      id: data.id,
      name: data.name,
      priceCents: data.priceCents,
      // ...
    };
  }
}

Camada de Apresentação (src/presentation/)

Interface com o mundo externo:

  • Controllers: Endpoints HTTP
  • Guards: Autenticação
  • Pipes: Validação
  • Modules: Organização NestJS
typescript
// src/presentation/controllers/case-opening.controller.ts
@Controller('cases')
@UseGuards(AuthGuard)
export class CaseOpeningController {
  constructor(
    private readonly openCaseUseCase: OpenCaseUseCase,
    private readonly verifyOpeningUseCase: VerifyOpeningUseCase,
  ) {}

  @Post(':id/open')
  @ApiOperation({ summary: 'Open a case' })
  @ApiResponse({ status: 201, type: CaseOpeningResponseDto })
  async openCase(
    @Param('id') caseId: string,
    @Body() dto: OpenCaseDto,
    @CurrentUser() user: User,
  ): Promise<CaseOpeningResponseDto> {
    const result = await this.openCaseUseCase.execute(
      user.id,
      BigInt(caseId),
      dto.clientSeed,
    );

    return {
      id: result.id.toString(),
      item: this.mapItemToDto(result.item),
      roll: result.roll,
      isFlip: result.isFlip,
      serverSeedHash: result.serverSeedHash,
    };
  }

  @Get('openings/:id/verify')
  @ApiOperation({ summary: 'Verify opening result' })
  async verifyOpening(
    @Param('id') openingId: string,
    @CurrentUser() user: User,
  ): Promise<VerifyOpeningResponseDto> {
    return this.verifyOpeningUseCase.execute(user.id, BigInt(openingId));
  }
}

Fluxo de Dependências

Controller → Use Case → Repository Interface

                     Repository Implementation

                           Prisma

                         PostgreSQL

O Controller depende do Use Case (ok). O Use Case depende da Interface do Repository (ok). A Implementação do Repository implementa a Interface (inversão de dependência).


Injeção de Dependências

Definição de Tokens

typescript
// src/domain/repositories/tokens.ts
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
export const CASE_REPOSITORY = Symbol('CASE_REPOSITORY');
export const TRANSACTION_REPOSITORY = Symbol('TRANSACTION_REPOSITORY');

Registro no Módulo

typescript
// src/presentation/modules/case.module.ts
@Module({
  imports: [DatabaseModule, SharedModule],
  controllers: [CaseController, CaseOpeningController],
  providers: [
    // Bind interface → implementação
    {
      provide: CASE_REPOSITORY,
      useClass: CaseRepository,
    },
    {
      provide: CASE_OPENING_REPOSITORY,
      useClass: CaseOpeningRepository,
    },
    // Use cases
    OpenCaseUseCase,
    VerifyOpeningUseCase,
    GetCaseHistoryUseCase,
  ],
  exports: [CASE_REPOSITORY],
})
export class CaseModule {}

Uso no Use Case

typescript
@Injectable()
export class OpenCaseUseCase {
  constructor(
    @Inject(CASE_REPOSITORY)
    private readonly caseRepository: ICaseRepository, // Interface!
  ) {}
}

Anti-Patterns Evitados

1. Controller com Lógica de Negócio

typescript
// ❌ ERRADO
@Post('open')
async openCase(@Body() dto: OpenCaseDto) {
  // Lógica no controller!
  const roll = Math.random() * 100000;
  const item = this.selectItem(roll);
  await this.prisma.caseOpening.create({ ... });
  return item;
}

// ✅ CORRETO
@Post('open')
async openCase(@Body() dto: OpenCaseDto, @CurrentUser() user: User) {
  return this.openCaseUseCase.execute(user.id, dto.caseId);
}

2. Use Case com Dependência de Framework

typescript
// ❌ ERRADO
@Injectable()
export class OpenCaseUseCase {
  constructor(
    @Inject(REQUEST) private request: Request, // HTTP no use case!
    private prisma: PrismaService, // Prisma direto no use case!
  ) {}
}

// ✅ CORRETO
@Injectable()
export class OpenCaseUseCase {
  constructor(
    @Inject(CASE_REPOSITORY)
    private caseRepository: ICaseRepository, // Interface apenas
  ) {}
}

3. Repository com Lógica de Negócio

typescript
// ❌ ERRADO
class UserRepository {
  async createWithBonus(data) {
    const bonus = data.isFirstDeposit ? 100 : 0; // Lógica no repo!
    return this.prisma.user.create({
      data: { ...data, balance: bonus },
    });
  }
}

// ✅ CORRETO - Lógica no Use Case
class CreateUserUseCase {
  async execute(data) {
    const bonus = data.isFirstDeposit ? 100 : 0; // Lógica aqui
    return this.userRepository.create({ ...data, balance: bonus });
  }
}

Benefícios no CSGOFlip

BenefícioResultado
Testabilidade95+ use cases testáveis sem infraestrutura
ManutenibilidadeCada arquivo tem responsabilidade única
EscalabilidadeNovos desenvolvedores encontram código facilmente
SubstituibilidadeTrocar banco/cache sem alterar lógica
SegurançaLógica financeira isolada e auditável

Arquivos Fonte Relacionados

Estrutura

  • src/domain/entities/ - Todas as entidades
  • src/domain/repositories/ - Todas as interfaces
  • src/application/use-cases/ - Todos os use cases
  • src/infrastructure/database/repositories/ - Implementações
  • src/presentation/modules/ - Configuração de DI

Documentação Técnica CSGOFlip