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:
// 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:
// 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:
// 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:
// 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:
// 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):
// 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:
// 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:
// 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
// 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:
// 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:
// 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/domaininstead - Getters/setters: Don't use getters and setters in classes
