Domain Driven Design
Why DDD Matters
Problem: Financial systems have complex business rules (zakat calculation, qard_hasan contracts, donation allocation) scattered across service layers, making them hard to maintain and validate.
Solution: Domain-Driven Design organizes business logic into rich domain models with explicit boundaries, clear invariants, and audit trails through domain events.
Benefits:
- Complex business rules live in domain model (not scattered services)
- Aggregates maintain data consistency through invariants
- Domain events provide clear audit trails for compliance
- Ubiquitous language bridges business and technical teams
- Explicit domain model matches legal requirements
Core Building Blocks
Value Objects
Problem: Primitive types (String, BigDecimal) don’t capture domain meaning or enforce business rules.
Solution: Immutable value objects defined by their attributes, with built-in validation and domain operations.
Pattern:
public record Money(BigDecimal amount, Currency currency) {
// => VALUE OBJECT: Immutable, equality by all fields
// => Compact constructor validates invariants before field assignment
public Money {
if (amount == null) {
throw new IllegalArgumentException("Amount cannot be null");
// => INVARIANT: amount must exist
}
if (currency == null) {
throw new IllegalArgumentException("Currency cannot be null");
// => INVARIANT: currency must exist
}
// => Ensure consistent scale (2 decimal places for currency)
amount = amount.setScale(2, RoundingMode.HALF_UP);
// => NORMALIZATION: amount is now always "123.45" format
}
// => FACTORY METHOD: Named constructor for clarity
public static Money of(BigDecimal amount, String currencyCode) {
return new Money(amount, Currency.getInstance(currencyCode));
// => Returns: Money with validated amount and ISO currency
}
// => FACTORY METHOD: Common case optimization
public static Money zero(Currency currency) {
return new Money(BigDecimal.ZERO, currency);
// => Returns: Money(0.00, currency) - useful for initialization
}
// => DOMAIN OPERATION: Addition with currency validation
// => Business operations return new instances (immutability)
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(this.currency, other.currency);
// => INVARIANT: Cannot add USD + EUR (must convert first)
}
return new Money(this.amount.add(other.amount), this.currency);
// => Returns: New Money instance with summed amount
// => Example: Money(100, USD) + Money(50, USD) => Money(150, USD)
}
// => DOMAIN OPERATION: Subtraction with currency validation
public Money subtract(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(this.currency, other.currency);
// => INVARIANT: Same currency required for subtraction
}
return new Money(this.amount.subtract(other.amount), this.currency);
// => Returns: New Money instance with difference
// => Example: Money(100, USD) - Money(30, USD) => Money(70, USD)
}
// => DOMAIN OPERATION: Scalar multiplication
public Money multiply(BigDecimal factor) {
return new Money(this.amount.multiply(factor), this.currency);
// => Returns: New Money with amount * factor
// => Example: Money(100, USD) * 1.5 => Money(150, USD)
}
// => QUERY METHOD: Check for negative values
public boolean isNegative() {
return amount.compareTo(BigDecimal.ZERO) < 0;
// => Returns: true if amount < 0
}
// => QUERY METHOD: Check for negative or zero
public boolean isNegativeOrZero() {
return amount.compareTo(BigDecimal.ZERO) <= 0;
// => Returns: true if amount <= 0
}
// => DOMAIN-SPECIFIC OPERATION: Islamic zakat calculation (2.5%)
public Money calculateZakat() {
return this.multiply(new BigDecimal("0.025"));
// => Returns: 2.5% of this amount
// => Example: Money(10000, USD).calculateZakat() => Money(250, USD)
}
// => DOMAIN-SPECIFIC OPERATION: Fee calculation
public Money calculateFee(FeeRate feeRate) {
return this.multiply(feeRate.value());
// => Returns: amount * fee rate
// => Example: Money(1000, USD).calculateFee(0.03) => Money(30, USD)
}
}Characteristics:
- Immutable: Cannot be modified after creation
- Defined by attributes: Two value objects with same values are equal
- No identity: No ID field, equality based on all attributes
- Self-validating: Validation in constructor
- Side-effect free: Operations return new instances
Email Value Object:
public record EmailAddress(String value) {
// => REGEX PATTERN: RFC 5322 simplified email validation
private static final Pattern EMAIL_PATTERN = Pattern.compile(
"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
);
// => Pattern matches: user@domain.tld format
// => COMPACT CONSTRUCTOR: Validates and normalizes input
public EmailAddress {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Email cannot be null or blank");
// => INVARIANT: Email must have content
}
String normalized = value.toLowerCase().trim();
// => NORMALIZATION: "User@Example.COM" -> "user@example.com"
// => Ensures: Case-insensitive equality, no whitespace
if (!EMAIL_PATTERN.matcher(normalized).matches()) {
throw new IllegalArgumentException("Invalid email format: " + value);
// => VALIDATION: Rejects malformed emails at construction
}
value = normalized;
// => ASSIGNMENT: Store normalized value
// => After this line, value field is guaranteed valid and normalized
}
// => FACTORY METHOD: Alternative constructor
public static EmailAddress of(String email) {
return new EmailAddress(email);
// => Returns: Validated and normalized EmailAddress
}
// => QUERY METHOD: Extract domain portion
public String getDomain() {
return value.substring(value.indexOf('@') + 1);
// => Returns: "example.com" from "user@example.com"
}
// => QUERY METHOD: Extract local part (username)
public String getLocalPart() {
return value.substring(0, value.indexOf('@'));
// => Returns: "user" from "user@example.com"
}
}Nisab Threshold Value Object:
public record NisabThreshold(Money amount, NisabType type) {
public NisabThreshold {
if (amount == null || amount.isNegativeOrZero()) {
throw new IllegalArgumentException("Nisab amount must be positive");
}
if (type == null) {
throw new IllegalArgumentException("Nisab type is required");
}
}
// Well-known nisabs
public static final NisabThreshold GOLD_85_GRAMS =
new NisabThreshold(Money.of(new BigDecimal("85"), "XAU"), NisabType.GOLD);
public static final NisabThreshold SILVER_595_GRAMS =
new NisabThreshold(Money.of(new BigDecimal("595"), "XAG"), NisabType.SILVER);
// Domain logic
public boolean isMetBy(Money balance) {
return balance.isGreaterThan(amount);
}
}
public enum NisabType {
GOLD,
SILVER,
CASH,
LIVESTOCK,
AGRICULTURAL_PRODUCE
}Entities
Problem: Some domain objects need unique identity and lifecycle tracking (donors, accounts, transactions).
Solution: Entities with unique IDs, mutable state, and encapsulated behavior.
Pattern:
public class Donor {
private final DonorId id;
private PersonName name; // Value Object
private EmailAddress email; // Value Object
private PhoneNumber phoneNumber; // Value Object (optional)
private Address address; // Value Object
private DonorStatus status;
private final LocalDateTime createdAt;
private LocalDateTime lastDonationAt;
// Private constructor - use factory methods
private Donor(
DonorId id,
PersonName name,
EmailAddress email,
PhoneNumber phoneNumber,
Address address,
DonorStatus status,
LocalDateTime createdAt) {
this.id = id;
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
this.address = address;
this.status = status;
this.createdAt = createdAt;
}
// Factory method enforces invariants
public static Donor register(
PersonName name,
EmailAddress email,
PhoneNumber phoneNumber,
Address address) {
DonorId id = DonorId.generate();
LocalDateTime now = LocalDateTime.now();
return new Donor(
id,
name,
email,
phoneNumber,
address,
DonorStatus.ACTIVE,
now
);
}
// Business methods express domain operations
public void updateContactInfo(EmailAddress newEmail, PhoneNumber newPhoneNumber) {
if (this.status == DonorStatus.SUSPENDED) {
throw new IllegalStateException("Cannot update suspended donor");
}
this.email = newEmail;
this.phoneNumber = newPhoneNumber;
}
public void recordDonation(LocalDateTime donationTime) {
this.lastDonationAt = donationTime;
}
public void suspend(String reason) {
if (this.status == DonorStatus.SUSPENDED) {
throw new IllegalStateException("Donor already suspended");
}
this.status = DonorStatus.SUSPENDED;
}
public void reactivate() {
if (this.status != DonorStatus.SUSPENDED) {
throw new IllegalStateException("Only suspended donors can be reactivated");
}
this.status = DonorStatus.ACTIVE;
}
// Equality based on ID
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Donor other)) return false;
return this.id.equals(other.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
// Getters
public DonorId getId() { return id; }
public PersonName getName() { return name; }
public EmailAddress getEmail() { return email; }
public DonorStatus getStatus() { return status; }
}
public enum DonorStatus {
ACTIVE,
SUSPENDED,
INACTIVE
}Strongly-Typed IDs:
Problem: Primitive String IDs can be accidentally swapped or mixed up.
Solution: Type-safe ID classes prevent compile-time errors.
// Base class for type-safe IDs
public abstract class TypedId {
private final String value;
protected TypedId(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("ID cannot be null or blank");
}
this.value = value;
}
public String getValue() {
return value;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
TypedId other = (TypedId) obj;
return value.equals(other.value);
}
@Override
public int hashCode() {
return value.hashCode();
}
}
// Specific ID types
public class DonorId extends TypedId {
private DonorId(String value) {
super(value);
}
public static DonorId of(String value) {
return new DonorId(value);
}
public static DonorId generate() {
return new DonorId("DONOR-" + UUID.randomUUID());
}
}
public class DonationId extends TypedId {
private DonationId(String value) {
super(value);
}
public static DonationId of(String value) {
return new DonationId(value);
}
public static DonationId generate() {
return new DonationId("DON-" + UUID.randomUUID());
}
}
// Benefit: Cannot mix up IDs at compile time
public void processDonation(DonationId donationId, DonorId donorId) {
// Type-safe!
}
// COMPILE ERROR: Cannot swap IDs
// processDonation(donorId, donationId);Characteristics:
- Unique identity: ID that persists throughout lifecycle
- Mutable: State can change (unlike value objects)
- Equality by ID: Two entities with same ID are equal
- Lifecycle: Created, modified, deleted
- Encapsulated behavior: Business logic within entity
Aggregates
Problem: Complex object graphs need consistency boundaries to maintain invariants across related entities.
Solution: Aggregates cluster entities and value objects into consistency boundaries with a single root entity controlling access.
Pattern:
public class ZakatAccount { // Aggregate Root
private final ZakatAccountId id;
private final DonorId donorId; // Reference by ID, not object
private Money balance;
private final NisabThreshold nisab;
private final LocalDate haulStartDate;
private final List<ZakatPayment> payments; // Child entities
private final List<DomainEvent> domainEvents;
// Private constructor
private ZakatAccount(
ZakatAccountId id,
DonorId donorId,
Money initialBalance,
NisabThreshold nisab,
LocalDate haulStartDate) {
this.id = id;
this.donorId = donorId;
this.balance = initialBalance;
this.nisab = nisab;
this.haulStartDate = haulStartDate;
this.payments = new ArrayList<>();
this.domainEvents = new ArrayList<>();
}
// Factory method enforces invariants
public static ZakatAccount open(
DonorId donorId,
Money initialBalance,
NisabThreshold nisab) {
if (initialBalance.isNegative()) {
throw new IllegalArgumentException("Initial balance cannot be negative");
}
LocalDate haulStart = LocalDate.now();
ZakatAccountId id = ZakatAccountId.generate();
ZakatAccount account = new ZakatAccount(
id,
donorId,
initialBalance,
nisab,
haulStart
);
account.recordEvent(new ZakatAccountOpened(
id,
donorId,
initialBalance,
haulStart
));
return account;
}
// Business methods maintain invariants
public void deposit(Money amount) {
if (amount.isNegativeOrZero()) {
throw new IllegalArgumentException("Deposit amount must be positive");
}
if (!amount.currency().equals(this.balance.currency())) {
throw new CurrencyMismatchException(
this.balance.currency(),
amount.currency()
);
}
this.balance = this.balance.add(amount);
recordEvent(new BalanceIncreased(this.id, amount, this.balance));
}
public ZakatCalculationResult calculateZakat(LocalDate currentDate) {
// Enforce invariant: haul must be complete
LocalDate haulEnd = haulStartDate.plusYears(1);
if (currentDate.isBefore(haulEnd)) {
long daysRemaining = ChronoUnit.DAYS.between(currentDate, haulEnd);
return new HaulIncomplete(haulStartDate, currentDate, daysRemaining);
}
// Check nisab
if (!nisab.isMetBy(balance)) {
return new BelowNisab(balance, nisab.amount());
}
// Calculate zakat
Money zakatDue = balance.calculateZakat();
return new ZakatDue(zakatDue, balance);
}
public void payZakat(Money amount, LocalDate paymentDate) {
// Validate payment
if (amount.isNegativeOrZero()) {
throw new IllegalArgumentException("Payment amount must be positive");
}
if (amount.isGreaterThan(balance)) {
throw new InsufficientBalanceException(balance, amount);
}
// Create payment (child entity)
ZakatPayment payment = new ZakatPayment(
ZakatPaymentId.generate(),
amount,
paymentDate
);
// Update aggregate state
this.payments.add(payment);
this.balance = this.balance.subtract(amount);
// Record domain event
recordEvent(new ZakatPaid(this.id, amount, paymentDate));
}
// Aggregate root controls access to children
public List<ZakatPayment> getPayments() {
return Collections.unmodifiableList(payments); // Defensive copy
}
// Aggregate root exposes domain events
public List<DomainEvent> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
public void clearDomainEvents() {
this.domainEvents.clear();
}
private void recordEvent(DomainEvent event) {
this.domainEvents.add(event);
}
// Getters
public ZakatAccountId getId() { return id; }
public DonorId getDonorId() { return donorId; }
public Money getBalance() { return balance; }
}
// Child entity (within aggregate)
class ZakatPayment {
private final ZakatPaymentId id;
private final Money amount;
private final LocalDate paymentDate;
ZakatPayment(ZakatPaymentId id, Money amount, LocalDate paymentDate) {
this.id = id;
this.amount = amount;
this.paymentDate = paymentDate;
}
// Getters only - mutations go through aggregate root
public ZakatPaymentId getId() { return id; }
public Money getAmount() { return amount; }
public LocalDate getPaymentDate() { return paymentDate; }
}Characteristics:
- Consistency boundary: Invariants enforced within aggregate
- Single aggregate root: External references go through root
- Atomic persistence: Saved/loaded as a unit
- Small size: Keep aggregates focused (avoid large object graphs)
- Reference by ID: Aggregates reference others by ID, not object reference
Aggregate Design Rules:
Rule 1: Model True Invariants in Consistency Boundaries
// GOOD: Zakat account maintains consistency
public class ZakatAccount {
public void payZakat(Money amount) {
// Invariant: balance >= amount
if (amount.isGreaterThan(balance)) {
throw new InsufficientBalanceException(balance, amount);
}
this.balance = this.balance.subtract(amount);
// Consistency maintained within transaction
}
}
// BAD: Splitting causes inconsistency risk
public class ZakatPaymentServiceBad {
@Transactional
public void payZakat(ZakatAccountId accountId, Money amount) {
ZakatAccountBad account = repository.findById(accountId);
// Race condition! Another thread might withdraw between these lines
if (amount.isGreaterThan(account.getBalance())) {
throw new InsufficientBalanceException();
}
account.setBalance(account.getBalance().subtract(amount));
// Invariant could be violated!
}
}Rule 2: Design Small Aggregates
// GOOD: Small aggregate focused on core invariants
public class Donation {
private final DonationId id;
private final DonorId donorId; // Reference by ID
private Money amount;
private DonationStatus status;
// Focused on donation lifecycle only
}
// BAD: Large aggregate with unnecessary data
public class DonationBad {
private final DonationId id;
private Donor donor; // Entire donor object!
private Fund fund; // Entire fund object!
private List<Transaction> relatedTransactions; // Unbounded!
private List<AuditLog> auditLogs; // Unbounded!
// Too much data, performance problems
}Rule 3: Reference Other Aggregates by ID
// GOOD: Reference by ID
public class Donation {
private final DonationId id;
private final DonorId donorId; // ID only
private final FundId fundId; // ID only
}
// Service loads aggregates separately
@Service
public class DonationService {
public void allocateDonation(DonationId donationId, FundId fundId) {
Donation donation = donationRepository.findById(donationId)
.orElseThrow();
Fund fund = fundRepository.findById(fundId)
.orElseThrow();
// Each aggregate in separate transaction if needed
donation.allocateTo(fundId);
fund.receiveAllocation(donation.getAmount());
donationRepository.save(donation);
fundRepository.save(fund);
}
}
// BAD: Direct object references
public class DonationBad {
private Donor donor; // Direct reference
private Fund fund; // Direct reference
// Violates aggregate boundaries!
}Rule 4: Update One Aggregate Per Transaction
// GOOD: Update one aggregate
@Transactional
public void processDonation(DonationId donationId) {
Donation donation = donationRepository.findById(donationId)
.orElseThrow();
donation.process(); // Single aggregate modified
donationRepository.save(donation);
eventPublisher.publishAll(donation.getDomainEvents());
// Domain events trigger eventual consistency for other aggregates
}
// ACCEPTABLE: Multiple aggregates if immediate consistency required
@Transactional
public void transferFunds(AccountId fromId, AccountId toId, Money amount) {
Account from = accountRepository.findById(fromId).orElseThrow();
Account to = accountRepository.findById(toId).orElseThrow();
// Withdraw and deposit must be atomic
from.withdraw(amount);
to.deposit(amount);
accountRepository.save(from);
accountRepository.save(to);
}Domain Events
Problem: Aggregates need to communicate state changes for audit trails, eventual consistency, and side effects without tight coupling.
Solution: Immutable domain events capture significant occurrences with complete data and timestamps.
Pattern:
// Base interface
public interface DomainEvent {
String getEventId();
LocalDateTime getOccurredAt();
}
// Concrete events
public record DonationCreated(
String eventId,
DonationId donationId,
DonorId donorId,
Money amount,
LocalDateTime occurredAt
) implements DomainEvent {
public DonationCreated(DonationId donationId, DonorId donorId, Money amount) {
this(
UUID.randomUUID().toString(),
donationId,
donorId,
amount,
LocalDateTime.now()
);
}
@Override
public String getEventId() { return eventId; }
@Override
public LocalDateTime getOccurredAt() { return occurredAt; }
}
public record DonationProcessed(
String eventId,
DonationId donationId,
Money netAmount,
Money fee,
LocalDateTime occurredAt
) implements DomainEvent {
public DonationProcessed(DonationId donationId, Money netAmount, Money fee) {
this(
UUID.randomUUID().toString(),
donationId,
netAmount,
fee,
LocalDateTime.now()
);
}
@Override
public String getEventId() { return eventId; }
@Override
public LocalDateTime getOccurredAt() { return occurredAt; }
}
public record ZakatPaid(
String eventId,
ZakatAccountId accountId,
Money amount,
LocalDate paymentDate,
LocalDateTime occurredAt
) implements DomainEvent {
public ZakatPaid(ZakatAccountId accountId, Money amount, LocalDate paymentDate) {
this(
UUID.randomUUID().toString(),
accountId,
amount,
paymentDate,
LocalDateTime.now()
);
}
@Override
public String getEventId() { return eventId; }
@Override
public LocalDateTime getOccurredAt() { return occurredAt; }
}Event Publishing:
public class Donation {
private final List<DomainEvent> domainEvents = new ArrayList<>();
// Business method records event
public void process() {
// ... business logic ...
recordEvent(new DonationProcessed(
this.id,
this.netAmount,
this.fee
));
}
private void recordEvent(DomainEvent event) {
this.domainEvents.add(event);
}
public List<DomainEvent> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
public void clearDomainEvents() {
this.domainEvents.clear();
}
}
// Service publishes events after save
@Service
public class DonationApplicationService {
private final DonationRepository donationRepository;
private final DomainEventPublisher eventPublisher;
@Transactional
public void processDonation(DonationId donationId) {
Donation donation = donationRepository.findById(donationId)
.orElseThrow();
donation.process();
donationRepository.save(donation);
// Publish events after successful save
eventPublisher.publishAll(donation.getDomainEvents());
donation.clearDomainEvents();
}
}Event Handlers:
@Component
public class DonationEventHandler {
private final EmailService emailService;
private final DonorRepository donorRepository;
// Handle domain event
@EventListener
@Transactional
public void onDonationProcessed(DonationProcessed event) {
// Update donor last donation timestamp
Donor donor = donorRepository.findByDonationId(event.donationId())
.orElseThrow();
donor.recordDonation(event.occurredAt());
donorRepository.save(donor);
// Send thank you email (side effect)
emailService.sendDonationReceipt(
donor.getEmail(),
event.donationId(),
event.netAmount()
);
}
@EventListener
public void onZakatPaid(ZakatPaid event) {
// Update read model, send notifications, etc.
logger.info("Zakat paid: {} for account {}",
event.amount(), event.accountId());
}
}Characteristics:
- Immutable: Cannot be changed once created
- Past tense naming: DonationProcessed, ZakatPaid, AccountOpened
- Timestamp: When event occurred
- Complete data: Contains all relevant information
- Aggregate ID: Which aggregate produced the event
Repositories
Problem: Domain model needs persistence without knowing database details.
Solution: Repository pattern abstracts storage/retrieval of aggregates with collection-like interface.
Pattern:
// Domain layer - persistence-agnostic interface
public interface DonationRepository {
// Save aggregate
void save(Donation donation);
// Find by ID
Optional<Donation> findById(DonationId id);
// Delete aggregate
void remove(DonationId id);
// Query methods (return aggregates or IDs)
List<Donation> findByDonor(DonorId donorId);
List<Donation> findByStatus(DonationStatus status);
List<DonationId> findPendingDonationIds();
// Existence check
boolean exists(DonationId id);
}JPA Repository Implementation:
// Infrastructure layer - JPA implementation
@Repository
public class JpaDonationRepository implements DonationRepository {
private final DonationJpaRepository jpaRepository;
public JpaDonationRepository(DonationJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public void save(Donation donation) {
DonationEntity entity = DonationMapper.toEntity(donation);
jpaRepository.save(entity);
}
@Override
public Optional<Donation> findById(DonationId id) {
return jpaRepository.findById(id.getValue())
.map(DonationMapper::toDomain);
}
@Override
public void remove(DonationId id) {
jpaRepository.deleteById(id.getValue());
}
@Override
public List<Donation> findByDonor(DonorId donorId) {
return jpaRepository.findByDonorId(donorId.getValue())
.stream()
.map(DonationMapper::toDomain)
.toList();
}
@Override
public List<Donation> findByStatus(DonationStatus status) {
return jpaRepository.findByStatus(status)
.stream()
.map(DonationMapper::toDomain)
.toList();
}
@Override
public List<DonationId> findPendingDonationIds() {
return jpaRepository.findIdsByStatus(DonationStatus.PENDING)
.stream()
.map(DonationId::of)
.toList();
}
@Override
public boolean exists(DonationId id) {
return jpaRepository.existsById(id.getValue());
}
}
// Spring Data JPA interface
interface DonationJpaRepository extends JpaRepository<DonationEntity, String> {
List<DonationEntity> findByDonorId(String donorId);
List<DonationEntity> findByStatus(DonationStatus status);
@Query("SELECT d.id FROM DonationEntity d WHERE d.status = :status")
List<String> findIdsByStatus(@Param("status") DonationStatus status);
}Characteristics:
- One repository per aggregate root: Not per entity
- Collection-like interface: Add, remove, find methods
- Persistence agnostic: Domain doesn’t know about database
- Atomic operations: Save/load entire aggregate
Repository Anti-Patterns:
Don’t expose implementation details:
// BAD: Leaks JPA details
public interface DonationRepository {
EntityManager getEntityManager(); // NO!
void flush(); // NO!
void refresh(Donation donation); // NO!
}Don’t create repositories for non-aggregates:
// BAD: Repository for child entity
public interface ZakatPaymentRepository { // NO!
// ZakatPayment is part of ZakatAccount aggregate
// Access through ZakatAccountRepository instead
}
// GOOD: Access through aggregate root
public interface ZakatAccountRepository {
Optional<ZakatAccount> findById(ZakatAccountId id);
// Returns entire aggregate including payments
}Don’t return DTOs from repositories:
// BAD: Returns DTO
public interface DonationRepository {
List<DonationDTO> findAll(); // NO!
}
// GOOD: Returns domain objects
public interface DonationRepository {
List<Donation> findAll();
}Domain Services
Problem: Some domain logic doesn’t naturally fit within entities or value objects (multi-aggregate operations, complex calculations, policy enforcement).
Solution: Domain services contain stateless domain logic spanning multiple aggregates or implementing complex algorithms.
Pattern - Zakat Calculation Service:
@Service
public class ZakatCalculationService {
// Domain logic that doesn't belong to any single entity
public ZakatCalculationResult calculateZakatDue(
Money balance,
NisabThreshold nisab,
LocalDate haulStartDate,
LocalDate currentDate) {
// Complex business rules
LocalDate haulEndDate = haulStartDate.plusYears(1);
if (currentDate.isBefore(haulEndDate)) {
long daysRemaining = ChronoUnit.DAYS.between(currentDate, haulEndDate);
return new HaulIncomplete(haulStartDate, currentDate, daysRemaining);
}
if (!nisab.isMetBy(balance)) {
return new BelowNisab(balance, nisab.amount());
}
Money zakatDue = balance.calculateZakat();
return new ZakatDue(zakatDue, balance);
}
public Money convertToLocalCurrency(Money amount, Currency targetCurrency) {
// Currency conversion logic
if (amount.currency().equals(targetCurrency)) {
return amount;
}
ExchangeRate rate = exchangeRateService.getRate(
amount.currency(),
targetCurrency
);
return amount.convert(rate, targetCurrency);
}
}Pattern - Donation Allocation Service:
@Service
public class DonationAllocationService {
// Coordinates allocation across multiple aggregates
@Transactional
public AllocationResult allocate(
DonationId donationId,
List<AllocationRule> rules) {
Donation donation = donationRepository.findById(donationId)
.orElseThrow();
List<FundAllocation> allocations = calculateAllocations(
donation.getAmount(),
rules
);
// Publish event for eventual consistency
eventPublisher.publish(new DonationAllocated(
donationId,
allocations,
LocalDateTime.now()
));
return new AllocationResult(donationId, allocations);
}
private List<FundAllocation> calculateAllocations(
Money totalAmount,
List<AllocationRule> rules) {
// Complex allocation algorithm
BigDecimal totalPercentage = rules.stream()
.map(AllocationRule::percentage)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalPercentage.compareTo(BigDecimal.ONE) != 0) {
throw new InvalidAllocationException("Percentages must sum to 100%");
}
return rules.stream()
.map(rule -> new FundAllocation(
rule.fundId(),
totalAmount.multiply(rule.percentage())
))
.toList();
}
}
public record AllocationRule(FundId fundId, BigDecimal percentage) {
public AllocationRule {
if (percentage.compareTo(BigDecimal.ZERO) <= 0 ||
percentage.compareTo(BigDecimal.ONE) > 0) {
throw new IllegalArgumentException("Percentage must be (0, 1]");
}
}
}When to Use Domain Services:
- Multi-aggregate operations: Logic spanning multiple aggregates
- Complex calculations: Algorithms that don’t belong to one entity
- External system integration: Calling external services with domain logic
- Policy enforcement: Business rules that aren’t entity-specific
Bounded Contexts
Problem: Large domains become unmanageable monoliths with conflicting models.
Solution: Bounded contexts establish explicit boundaries where specific domain models apply with their own ubiquitous language.
Pattern:
Financial platform bounded contexts:
- Donation Context: Donors, donations, campaigns
- Zakat Context: Zakat accounts, nisab thresholds, haul periods
- Qard Hasan Context: Loans, repayment schedules, borrowers
- Fund Management Context: Funds, allocations, disbursements
Each context maintains its own model of shared concepts (e.g., “Account” means different things in Donation vs Zakat contexts).
Ubiquitous Language
Problem: Business and technical teams use different terminology, causing miscommunication.
Solution: Establish and enforce shared vocabulary used in code, documentation, and conversations.
Pattern:
Business terms become code:
- “Nisab threshold” →
NisabThresholdvalue object - “Haul period” →
haulStartDatefield with validation - “Qard Hasan” →
QardHasanLoanaggregate - “Zakat obligation” →
calculateZakat()method
Code reflects business language exactly.
Spring Boot Integration
Application Service Pattern:
@Service
@Transactional
public class DonationApplicationService {
private final DonationRepository donationRepository;
private final DonorRepository donorRepository;
private final DomainEventPublisher eventPublisher;
public DonationApplicationService(
DonationRepository donationRepository,
DonorRepository donorRepository,
DomainEventPublisher eventPublisher) {
this.donationRepository = donationRepository;
this.donorRepository = donorRepository;
this.eventPublisher = eventPublisher;
}
public DonationId createDonation(CreateDonationCommand command) {
// Validate donor exists
Donor donor = donorRepository.findById(command.donorId())
.orElseThrow(() -> new DonorNotFoundException(command.donorId()));
// Create aggregate
Donation donation = Donation.create(
command.donorId(),
command.amount()
);
// Persist
donationRepository.save(donation);
// Publish events
eventPublisher.publishAll(donation.getDomainEvents());
donation.clearDomainEvents();
return donation.getId();
}
public void processDonation(ProcessDonationCommand command) {
Donation donation = donationRepository.findById(command.donationId())
.orElseThrow(() -> new DonationNotFoundException(command.donationId()));
donation.process();
donationRepository.save(donation);
eventPublisher.publishAll(donation.getDomainEvents());
donation.clearDomainEvents();
}
}REST Controller:
@RestController
@RequestMapping("/api/donations")
public class DonationController {
private final DonationApplicationService donationService;
private final DonationQueryService queryService;
@PostMapping
public ResponseEntity<DonationResponse> createDonation(
@RequestBody @Valid CreateDonationRequest request) {
CreateDonationCommand command = new CreateDonationCommand(
DonorId.of(request.donorId()),
Money.of(request.amount(), request.currency())
);
DonationId donationId = donationService.createDonation(command);
DonationResponse response = queryService.findById(donationId)
.map(DonationResponse::from)
.orElseThrow();
return ResponseEntity.ok(response);
}
@PostMapping("/{id}/process")
public ResponseEntity<Void> processDonation(@PathVariable String id) {
DonationId donationId = DonationId.of(id);
ProcessDonationCommand command = new ProcessDonationCommand(donationId);
donationService.processDonation(command);
return ResponseEntity.accepted().build();
}
@GetMapping("/{id}")
public ResponseEntity<DonationResponse> getDonation(@PathVariable String id) {
DonationId donationId = DonationId.of(id);
return queryService.findById(donationId)
.map(DonationResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}Axon Framework (CQRS and Event Sourcing)
Problem: Traditional CRUD doesn’t capture full history or separate read/write concerns.
Solution: Axon Framework implements CQRS (Command Query Responsibility Segregation) and Event Sourcing.
Installation:
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
<version>4.10.3</version>
</dependency>Aggregate with Axon:
@Aggregate
public class Donation {
@AggregateIdentifier
private DonationId donationId;
private DonorId donorId;
private Money amount;
private DonationStatus status;
// Required no-arg constructor for Axon
protected Donation() {}
// Command handler: creates aggregate
@CommandHandler
public Donation(CreateDonationCommand command) {
// Validate
if (command.amount().isNegativeOrZero()) {
throw new IllegalArgumentException("Amount must be positive");
}
// Apply event
apply(new DonationCreatedEvent(
command.donationId(),
command.donorId(),
command.amount()
));
}
// Event sourcing handler: rebuilds state
@EventSourcingHandler
public void on(DonationCreatedEvent event) {
this.donationId = event.donationId();
this.donorId = event.donorId();
this.amount = event.amount();
this.status = DonationStatus.PENDING;
}
// Command handler: modifies aggregate
@CommandHandler
public void handle(ProcessDonationCommand command) {
// Guard
if (this.status != DonationStatus.PENDING) {
throw new IllegalStateException("Can only process pending donations");
}
// Calculate
Money fee = this.amount.calculateFee(FeeRate.STANDARD);
Money netAmount = this.amount.subtract(fee);
// Apply event
apply(new DonationProcessedEvent(
this.donationId,
netAmount,
fee
));
}
@EventSourcingHandler
public void on(DonationProcessedEvent event) {
this.status = DonationStatus.PROCESSED;
}
}
// Commands
public record CreateDonationCommand(
@TargetAggregateIdentifier DonationId donationId,
DonorId donorId,
Money amount
) {}
public record ProcessDonationCommand(
@TargetAggregateIdentifier DonationId donationId
) {}
// Events
public record DonationCreatedEvent(
DonationId donationId,
DonorId donorId,
Money amount
) {}
public record DonationProcessedEvent(
DonationId donationId,
Money netAmount,
Money fee
) {}Sending Commands:
@Service
public class DonationCommandService {
private final CommandGateway commandGateway;
// Send command
public CompletableFuture<DonationId> createDonation(
DonorId donorId,
Money amount) {
DonationId donationId = DonationId.generate();
CreateDonationCommand command = new CreateDonationCommand(
donationId,
donorId,
amount
);
return commandGateway.send(command);
}
public CompletableFuture<Void> processDonation(DonationId donationId) {
ProcessDonationCommand command = new ProcessDonationCommand(donationId);
return commandGateway.send(command);
}
}Query Side (CQRS):
// Query model (read-optimized)
@Entity
@Table(name = "donation_query")
public class DonationQueryModel {
@Id
private String donationId;
private String donorId;
private BigDecimal amount;
private String currency;
private String status;
private LocalDateTime createdAt;
private LocalDateTime processedAt;
// Getters, setters, constructors
}
// Event handler updates query model
@Component
public class DonationQueryModelUpdater {
@Autowired
private DonationQueryRepository queryRepository;
@EventHandler
public void on(DonationCreatedEvent event) {
DonationQueryModel model = new DonationQueryModel();
model.setDonationId(event.donationId().getValue());
model.setDonorId(event.donorId().getValue());
model.setAmount(event.amount().getAmount());
model.setCurrency(event.amount().currency().getCurrencyCode());
model.setStatus("PENDING");
model.setCreatedAt(LocalDateTime.now());
queryRepository.save(model);
}
@EventHandler
public void on(DonationProcessedEvent event) {
queryRepository.findById(event.donationId().getValue())
.ifPresent(model -> {
model.setStatus("PROCESSED");
model.setProcessedAt(LocalDateTime.now());
queryRepository.save(model);
});
}
}
// Query handler
@Component
public class DonationQueryHandler {
@Autowired
private DonationQueryRepository queryRepository;
@QueryHandler
public List<DonationQueryModel> handle(FindDonationsByDonorQuery query) {
return queryRepository.findByDonorId(query.donorId().getValue());
}
@QueryHandler
public Optional<DonationQueryModel> handle(FindDonationByIdQuery query) {
return queryRepository.findById(query.donationId().getValue());
}
}
// Queries
public record FindDonationsByDonorQuery(DonorId donorId) {}
public record FindDonationByIdQuery(DonationId donationId) {}Testing DDD Code
Testing Value Objects:
@Test
void testMoneyAddition() {
Money a = Money.of(new BigDecimal("100"), "USD");
Money b = Money.of(new BigDecimal("50"), "USD");
Money sum = a.add(b);
assertThat(sum.getAmount()).isEqualByComparingTo(new BigDecimal("150"));
}
@Test
void testCannotAddDifferentCurrencies() {
Money usd = Money.of(new BigDecimal("100"), "USD");
Money eur = Money.of(new BigDecimal("50"), "EUR");
assertThrows(CurrencyMismatchException.class, () -> usd.add(eur));
}Testing Aggregates:
@Test
void testCreateDonation() {
DonorId donorId = DonorId.of("DONOR-123");
Money amount = Money.of(new BigDecimal("1000"), "USD");
Donation donation = Donation.create(donorId, amount);
assertThat(donation.getId()).isNotNull();
assertThat(donation.getAmount()).isEqualTo(amount);
assertThat(donation.getStatus()).isEqualTo(DonationStatus.PENDING);
// Verify event recorded
assertThat(donation.getDomainEvents())
.hasSize(1)
.first()
.isInstanceOf(DonationCreated.class);
}
@Test
void testProcessDonation() {
Donation donation = createTestDonation();
donation.process();
assertThat(donation.getStatus()).isEqualTo(DonationStatus.PROCESSED);
// Verify event
List<DomainEvent> events = donation.getDomainEvents();
DonationProcessed processedEvent = (DonationProcessed) events.get(1);
assertThat(processedEvent.donationId()).isEqualTo(donation.getId());
}
@Test
void testCannotProcessNonPendingDonation() {
Donation donation = createTestDonation();
donation.process(); // Process once
donation.clearDomainEvents();
// Try to process again
assertThrows(IllegalStateException.class, () -> donation.process());
}Testing Domain Services:
@Test
void testZakatCalculationBelowNisab() {
Money balance = Money.of(new BigDecimal("4000"), "USD");
NisabThreshold nisab = new NisabThreshold(
Money.of(new BigDecimal("5000"), "USD"),
NisabType.CASH
);
LocalDate haulStart = LocalDate.of(2025, 1, 1);
LocalDate current = LocalDate.of(2026, 2, 1);
ZakatCalculationResult result = zakatService.calculateZakatDue(
balance,
nisab,
haulStart,
current
);
assertThat(result).isInstanceOf(BelowNisab.class);
}Implementation Checklist
Design Phase:
- Identify aggregates and their boundaries
- Design value objects for domain concepts (Money, Email, etc.)
- Determine aggregate roots and consistency boundaries
- Define domain events for significant occurrences
- Model entities with unique identity
- Identify domain services for multi-aggregate logic
Implementation Phase:
- Use records for value objects (immutable)
- Enforce invariants in aggregate roots
- Reference other aggregates by ID, not object
- Keep aggregates small and focused
- Use factories for complex creation
- Repositories return aggregates, not DTOs
- Domain events are immutable and past-tense
- One repository per aggregate root
Testing Phase:
- Test value object immutability
- Test aggregate invariants
- Test domain event recording
- Test domain service logic
- Integration tests for repositories
Code Review Phase:
- Aggregates maintain invariants
- Business logic in domain, not services
- Domain model is persistence-ignorant
- Ubiquitous language used in code
- Events capture all state changes