Deleting entries in a database is quite straightforward. In SQL, you might write something like:

DELETE FROM customer WHERE id = ?

This approach can be easily implemented in a repository using a language like Java, for example:

public void delete(CustomerId id) {
    customerRepository.deleteById(id);
}

In this example, the customer is deleted, and everything seems fine. But what if deleting the customer isn't actually a valid operation? For instance, what if the customer has outstanding bills?

This is why deletion should be validated. In domain-driven design, it's better to enforce such business rules through the domain model itself. The domain object can take responsibility for determining whether deletion is allowed.

For example:

public class Customer {
    // ... other fields
    List<Bill> bills;

    public CustomerDeletionResult assessDeletionEligibility() {
        final var hasUnpaidBills = bills.stream().anyMatch(Bill::isUnpaid);
        if (hasUnpaidBills) {
            return CustomerDeletionResult.CANNOT_DELETE_DUE_TO_UNPAID_BILLS;
        }

        return CustomerDeletionResult.SUCCESS;
    }
}

CustomerDeletionResult is an enum that indicates success or a specific domain reason why deletion is blocked. This method is simple to test and can easily accommodate more complex logic later on:

@Test
void assessDeletionEligibility_whenNoUnpaidBills_shouldReturnSuccess() {
	// Arrange: create a customer with only paid bills
	final var paidBill1 = BillTestData.defaultPaid().build();
	final var paidBill2 = BillTestData.defaultPaid().build();
	final var customer = CustomerTestData.builder()
		.addBill(paidBill1)
		.addBill(paidBill2)
		.build();

	// Act
	final var result = customer.assessDeletionEligibility();

	// Assert
	assertTrue(result.isSuccess());
}

@Test
void assessDeletionEligibility_whenHasUnpaidBills_shouldReturnCannotDelete() {
	// Arrange: create a customer with at least one unpaid bill
	final var paidBill = BillTestData.defaultPaid().build();
	final var unpaidBill = BillTestData.defaultUnpaid().build();
	final var customer = CustomerTestData.builder()
		.addBill(paidBill)
		.addBill(unpaidBill)
		.build();

	// Act
	final var result = customer.assessDeletionEligibility();

	// Assert
	assertEquals(CustomerDeletionResult.CANNOT_DELETE_DUE_TO_UNPAID_BILLS, result);
}

With this setup, your application service can now do:

public void delete(CustomerId id) {
    final var customer = customerRepository.get(id);

    final var deletionResult = customer.assessDeletionEligibility();

    if (deletionResult.isSuccess()) {
        customerRepository.deleteById(id);
    } else {
        throw new DeletionException(DeletionType.CUSTOMER, deletionResult);
    }
}

This way, deletion logic is enforced through the domain object. The exception structure allows handling different deletion cases (e.g., customer, order, invoice) uniformly at higher layers, while still preserving domain-specific reasons. As the rules evolve, you only need to update the domain logic, keeping the rest of the system clean and consistent.

Some might argue for pushing even more responsibility into the domain. For example, having the domain object itself perform the deletion or throw the exception. This post recommends keeping such control flow and coordination in the application layer, while still enforcing business rules in the domain. This topic is further explored in the previous post on Complexity vs. Collaboration.