Skip to content

DDD Principles

Overview

Switchain Services implements Domain-Driven Design (DDD) with Hexagonal Architecture (Ports & Adapters). The domain model is at the center, completely independent of frameworks, databases, and external services.

Layer Dependencies

Domain ← Application ← Infrastructure
  • Domain has no external dependencies (no Prisma, no Redis, no HTTP)
  • Application depends on Domain only
  • Infrastructure depends on Domain and Application, implements their interfaces

Domain Layer

Entities

Rich objects with business identity and behavior. Private constructor + static factory:

typescript
// packages/cex-wallets/domain/entities/CexAccount.ts
export class CexAccount {
  private constructor(
    public readonly id: CexAccountId,
    public readonly cex: Cex,
    public readonly active: boolean
  ) {}

  static create(params: { cex: string; active: boolean }): CexAccount {
    return new CexAccount(
      CexAccountId.create(),
      Cex.fromString(params.cex),
      params.active
    )
  }

  static fromDto(dto: CexAccountDto): CexAccount {
    return new CexAccount(
      CexAccountId.fromString(dto.id),
      Cex.fromString(dto.cex),
      dto.active
    )
  }

  toDto(): CexAccountDto {
    return {
      id: this.id.toString(),
      cex: this.cex.toString(),
      active: this.active
    }
  }
}

Value Objects

Immutable, validated types. Private constructor + static factory:

typescript
// packages/cex-wallets/domain/valueObjects/CexAccountId.ts
export class CexAccountId {
  private constructor(private readonly value: string) {}

  static create(): CexAccountId {
    return new CexAccountId(crypto.randomUUID())
  }

  static fromString(value: string): CexAccountId {
    if (!value) throw new BadRequestError('CexAccountId cannot be empty')
    return new CexAccountId(value)
  }

  equals(other: CexAccountId): boolean {
    return this.value === other.value
  }

  toString(): string {
    return this.value
  }
}

Repository Interfaces (Ports)

Defined in the domain layer. Infrastructure provides implementations:

typescript
// packages/cex-wallets/domain/repositories/CexAccountRepository.ts
import type { CexAccount } from '../entities/CexAccount'

export interface CexAccountRepository {
  findById(id: string): Promise<CexAccount | null>
  findAll(): Promise<CexAccount[]>
  save(account: CexAccount): Promise<void>
  delete(id: string): Promise<void>
}

Domain Services

Pure business logic that doesn't belong in a single entity:

typescript
// packages/rates/domain/services/PairsBuilder.ts
export class PairsBuilder {
  run({ coins, blockchains }: { coins: Coin[]; blockchains: CoinBlockchain[] }): Pair[] {
    // Pure business logic — no external dependencies
  }
}

Domain Events

Cross-boundary communication. Extend DomainEvent from @sws/common/domain:

typescript
// packages/cex-wallets/domain/events/CoinBlockchainCreatedDomainEvent.ts
import { DomainEvent } from '@sws/common/domain'

export class CoinBlockchainCreatedDomainEvent extends DomainEvent {
  static eventName = 'cex-wallets.coinBlockchainCreated'

  constructor(
    public readonly coinId: string,
    public readonly blockchain: string,
    occurredOn?: Date
  ) {
    super(CoinBlockchainCreatedDomainEvent.eventName, occurredOn)
  }
}

Ports (External Service Interfaces)

Interfaces for external services that the domain needs (e.g., CEX APIs):

typescript
// packages/cex-wallets/domain/ports/CexSpotAccountPort.ts
export interface CexSpotAccountPort {
  coinBlockchainList(cex: string): Promise<CoinBlockchainData[]>
  depositAddress(params: { cex: string; coinId: string; blockchain: string }): Promise<BlockchainAddress | null>
  withdraw(params: WithdrawParams): Promise<string>
}

Application Layer

Application Services

Orchestrate domain objects to fulfill use cases:

typescript
// packages/cex-wallets/application/services/CoinBlockchainCreator.ts
import { LoggerFactory } from '@sws/common/logger'
import type { CoinBlockchainRepository } from '../../domain/repositories/CoinBlockchainRepository'
import { CoinBlockchain } from '../../domain/entities/CoinBlockchain'
import { CoinBlockchainCreatedDomainEvent } from '../../domain/events/CoinBlockchainCreatedDomainEvent'

export class CoinBlockchainCreator {
  private logger = LoggerFactory.logger()

  constructor(
    private readonly repository: CoinBlockchainRepository,
    private readonly eventPublisher: EventPublisher
  ) {}

  async run({ coinId, blockchain }: { coinId: string; blockchain: string }): Promise<CoinBlockchain> {
    this.logger.info(`CoinBlockchainCreator.run coinId:${coinId}`)

    const entity = CoinBlockchain.create({ coinId, blockchain })
    await this.repository.save(entity)

    await this.eventPublisher.publish(
      new CoinBlockchainCreatedDomainEvent(coinId, blockchain)
    )

    return entity
  }
}

Event Subscribers

React to domain events from this or other bounded contexts:

typescript
// packages/cex-wallets/application/eventSubscribers/UpdateCexCoinsBlockchainsOnCoinBlockchainCreated.ts
import type { DomainEventSubscriber } from '@sws/common/domain'
import { LoggerFactory } from '@sws/common/logger'
import { CoinBlockchainCreatedDomainEvent } from '../../domain/events/CoinBlockchainCreatedDomainEvent'

export class UpdateCexCoinsBlockchainsOnCoinBlockchainCreated
  implements DomainEventSubscriber<CoinBlockchainCreatedDomainEvent>
{
  private logger = LoggerFactory.logger()

  subscribedTo() {
    return [CoinBlockchainCreatedDomainEvent]
  }

  async on(event: CoinBlockchainCreatedDomainEvent): Promise<void> {
    this.logger.info(`UpdateCexCoinsBlockchainsOnCoinBlockchainCreated coinId:${event.coinId}`)
    // React to the event
  }
}

Infrastructure Layer

Prisma Repositories

typescript
// packages/cex-wallets/infrastructure/repositories/CexAccountPrismaRepository.ts
import type { PrismaClient } from '@sws/database'
import type { CexAccountRepository } from '../../domain/repositories/CexAccountRepository'
import { CexAccount } from '../../domain/entities/CexAccount'

export class CexAccountPrismaRepository implements CexAccountRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async findById(id: string): Promise<CexAccount | null> {
    const data = await this.prisma.cexAccount.findUnique({ where: { id } })
    return data ? CexAccount.fromDto(data) : null
  }

  async save(account: CexAccount): Promise<void> {
    const data = account.toDto()
    await this.prisma.cexAccount.upsert({
      where: { id: data.id },
      create: data,
      update: data
    })
  }
}

Factories (Dependency Injection)

Centralize dependency creation. Support TestDependencyOverrides from @sws/common/test:

typescript
// packages/cex-wallets/infrastructure/factories/CexAccountFactory.ts
import type { TestDependencyOverrides } from '@sws/common/test'
import { client as prisma } from '@sws/database'
import { CexAccountPrismaRepository } from '../repositories/CexAccountPrismaRepository'
import { CoinBlockchainCreator } from '../../application/services/CoinBlockchainCreator'

export class CexAccountFactory {
  private static repositoryInstance: CexAccountPrismaRepository

  static cexAccountRepository(overrides?: TestDependencyOverrides) {
    if (overrides?.cexAccountRepository) return overrides.cexAccountRepository
    if (!CexAccountFactory.repositoryInstance) {
      CexAccountFactory.repositoryInstance = new CexAccountPrismaRepository(prisma)
    }
    return CexAccountFactory.repositoryInstance
  }

  static coinBlockchainCreator(overrides?: TestDependencyOverrides) {
    return new CoinBlockchainCreator(
      CexAccountFactory.cexAccountRepository(overrides),
      CommonFactory.eventPublisher()
    )
  }
}

Adapters

Implement domain port interfaces with external technology:

typescript
// packages/cex-wallets/infrastructure/adapters/CctxCexSpotAccountAdapter.ts
import type { CexSpotAccountPort } from '../../domain/ports/CexSpotAccountPort'

export class CctxCexSpotAccountAdapter implements CexSpotAccountPort {
  async coinBlockchainList(cex: string): Promise<CoinBlockchainData[]> {
    // Call external CEX API via CCXT library
  }
}

Anti-Patterns to Avoid

  • Domain importing infrastructure: Never import Prisma in domain layer
  • Anemic domain models: Entities should have behavior, not just be data holders
  • Skipping the factory: Always use factories for dependency injection
  • Direct new Error(): Use domain errors from @sws/common/domain instead
  • Getters/setters: Don't use getters and setters in classes