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:
// 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:
// ❌ 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:
// 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 # CancelarCamadas 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
// 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
// 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
// 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
// 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
↓
PostgreSQLO 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
// 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
// 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
@Injectable()
export class OpenCaseUseCase {
constructor(
@Inject(CASE_REPOSITORY)
private readonly caseRepository: ICaseRepository, // Interface!
) {}
}Anti-Patterns Evitados
1. Controller com Lógica de Negócio
// ❌ 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
// ❌ 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
// ❌ 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ício | Resultado |
|---|---|
| Testabilidade | 95+ use cases testáveis sem infraestrutura |
| Manutenibilidade | Cada arquivo tem responsabilidade única |
| Escalabilidade | Novos desenvolvedores encontram código facilmente |
| Substituibilidade | Trocar banco/cache sem alterar lógica |
| Segurança | Lógica financeira isolada e auditável |
Arquivos Fonte Relacionados
Estrutura
src/domain/entities/- Todas as entidadessrc/domain/repositories/- Todas as interfacessrc/application/use-cases/- Todos os use casessrc/infrastructure/database/repositories/- Implementaçõessrc/presentation/modules/- Configuração de DI
