DEV Community

Cover image for Beyond DDD Vocabulary: Returning to Real Business Objects
雷叮当
雷叮当

Posted on

Beyond DDD Vocabulary: Returning to Real Business Objects

Real Domain Modeling Is Not About Renaming Service into Domain Service

This article was written by a human and lightly refined with AI for clarity and readability.

There are many DDD articles and many so-called DDD demos. They usually come with a familiar vocabulary: aggregate, entity, value object, domain service, repository, bounded context. It looks complete. It looks professional. It looks like domain modeling.

But if you actually read the code, a very awkward fact appears:

they talk about the domain, but they still write service scripts.

A so-called domain service is often just an ordinary service: load data, make a few decisions, mutate a few fields, save the result. The core business invariant is not placed on a real business object. It only survives inside the execution order of the service. The names changed, the structure did not, and the business was never actually modeled.

That is the thing I want to criticize.

Real domain modeling does not begin by memorizing vocabulary and splitting a project into Entity, Repository, and Service. It begins by finding the real business object. Only then can the system actually organize itself around the business. Otherwise DDD often becomes nothing more than service mode wearing more advanced terminology.

What a real business object is

A very simple question helps:

If this object did not exist, would the system still make sense?

If the answer is no, then that object deserves to become the center of the model.

But that is still not enough. Many people accept this idea in theory and still fall back to the same implementation pattern: the service loads data, the service makes decisions, the service mutates fields, and the service saves results. In the end the so-called business object is only a better-looking data shell.

So the real point is not just whether the object exists in the system. The real point is this:

does the object actually perform business calculation and actually carry business invariants?

A real business object is not a passive structure that only stores fields, and not a helper class serving a service. At minimum it should:

  1. Receive the input required by the business
  2. Execute business rules internally
  3. Produce a complete business result
  4. Carry the key invariant instead of leaving it to service execution order

If those things are missing, then the object is not the business center.

Why many systems choose the wrong center from the beginning

Many systems start by splitting everything by nouns: user, product, order, account, book. That is not entirely wrong, but business does not stand on nouns. It stands on constraints.

What a system truly depends on is often not "what nouns appear in the system," but:

  1. What facts must hold together
  2. What results must be calculated together
  3. What invariants, if lost, make the system meaningless

That is why the real business object is often not the first noun people see. It is the object that carries the key relationship, the key result, and the key business consistency.

In bank transfer, the center is not the account. It is the transfer.

Bank transfer is the clearest example.

Many people assume the center of a transfer system must be the account. After all, accounts have balances, and transfer eventually changes two balances.

So the code often becomes:

  1. Load the source account
  2. Load the target account
  3. Check whether the balance is enough
  4. Subtract from one account
  5. Add to the other account
  6. Save both accounts

That code can run, but the problem is obvious:

the business itself was never modeled.

What matters is not that two accounts changed. What matters is that a transfer happened. What matters is not that some fields happened to be updated. What matters is that the transfer must satisfy a full set of business constraints:

  1. The source amount cannot exceed the available balance
  2. Value must be conserved before and after transfer
  3. Fees must be calculated
  4. Tax rules must be applied
  5. Some account types may forbid some transfers
  6. Large transfers may require extra rules

If all of that stays inside a service, the system has not modeled transfer. It only scripted a flow that changes two balances.

So in this example the real business object is not Account. It is Transfer.

Without Transfer, the system has no proper expression of transfer itself. It only has tables, fields, and save operations.

A common but wrong pattern: renaming a service into DomainService

Many DDD examples online go one step further and rename the flow service into TransferDomainService to make it look more domain-driven:

public class TransferDomainService {

    private final AccountRepository accountRepository;
    private final TaxService taxService;
    private final RiskControlService riskControlService;

    public void transfer(String fromAccountId, String toAccountId, BigDecimal amount) {
        Account from = accountRepository.findById(fromAccountId);
        Account to = accountRepository.findById(toAccountId);

        if (from.getBalance().compareTo(amount) < 0) {
            throw new IllegalArgumentException("Insufficient balance");
        }

        if (!riskControlService.allowTransfer(from, to, amount)) {
            throw new IllegalArgumentException("Transfer rejected by risk control");
        }

        BigDecimal tax = taxService.calculate(amount);

        from.setBalance(from.getBalance().subtract(amount).subtract(tax));
        to.setBalance(to.getBalance().add(amount));

        accountRepository.save(from);
        accountRepository.save(to);
    }
}
Enter fullscreen mode Exit fullscreen mode

This kind of code is misleading precisely because it already has the visual shape of DDD: entities, repositories, a so-called domain service, and even tax and risk dependencies.

But the problem did not change.

The real business object, Transfer, still does not exist. The real calculation and the real invariant are still trapped in the service.

In other words, this is not a business object carrying the business. It is just a better-named service carrying the business.

So the issue is never whether the class is called Service or DomainService.

The issue is this: is the business really carried by an object, or is it still only assembled by a flow?

Another way to discover the real business object: look at what hurts when you test the system

Many people look for business boundaries by starting with nouns: account, order, inventory, tax, risk control.

But there is a more direct and more honest method: look at what actually hurts when you try to test the system.

Suppose you only want to verify one core business rule:

"Transfer 100 from account A to account B, apply risk control, apply tax and fees, and make sure the result is correct."

That should be a very pure business validation.

Yet in many systems, to validate that one rule, you end up having to start:

  1. The account system
  2. The payment system
  3. The risk-control system
  4. The tax system
  5. The audit system
  6. The database, cache, and message infrastructure

At that point you realize that you are supposedly testing "transfer," yet you have to boot half the platform just to do it.

That pain is not accidental.

It usually means that transfer has not been gathered into one business object that can stand on its own. Its rules were scattered across multiple services, modules, and surrounding systems, so you cannot validate "transfer itself." You can only reconstruct it by booting the entire environment.

That is an extremely valuable signal:

the parts you are forced to drag into the same test often reveal where the real business object actually is.

So one practical way to discover the real business object is to ask:

  1. Which rules always participate in the same calculation?
  2. Which data always has to appear together?
  3. Which results must always hold together?
  4. And to verify them, do you always need to boot multiple systems together?

If the answer keeps pointing to the same cluster, that is usually where the real business object is hiding.

For transfer, that object is not the account repository, the tax API, or the risk-control service. It is the transfer itself.

If transfer is modeled correctly as a business object, its core rules should be calculable and testable directly in memory without pulling in the surrounding systems.

In a library system, borrowing is the center

A library system makes the same point in a different way.

Many people would mechanically split it into:

  1. User domain
  2. Book domain
  3. Borrowing domain
  4. Fine domain

It looks neat, but the real question is:

who decides whether a user can borrow a book?

The book object may know inventory, but it does not know the user's credit, overdue state, or borrowing history.

The user object may know its own credit and borrow count, but it does not know whether the target book is available.

So the question belongs to borrowing itself.

The right question is not:

"Can this user borrow?"

"Is this book borrowable?"

The right question is:

in this borrowing event, can this user borrow this specific book?

That is why borrowing, not user and not book, becomes the real business center.

Once the business object is clear, how should it be used?

At that point a practical question appears:

how does this actually land in code?

The structure is simple. A healthy business flow usually has three steps:

  1. Service queries the necessary data
  2. The data is handed to the business object to calculate the result
  3. The result is persisted

In other words:

  • Service orchestrates
  • Domain calculates
  • Repository persists

The important point is that step two must really exist.

If the service still performs the real decisions, real calculations, and real invariants by itself, then you only have the form of a domain layer while still remaining in service mode.

The proper shape of transfer

Once transfer is treated as the business object, the flow should look like this:

public class TransferService {

    private final AccountRepository accountRepository;
    private final TransferRuleRepository transferRuleRepository;

    public void transfer(String fromAccountId, String toAccountId, BigDecimal amount) {
        Account from = accountRepository.findById(fromAccountId);
        Account to = accountRepository.findById(toAccountId);
        List<TransferRule> rules = transferRuleRepository.findRules(from, to, amount);

        Transfer transfer = new Transfer(
                from.getBalance(),
                to.getBalance(),
                amount,
                rules
        );

        TransferResult result = transfer.execute();

        from.setBalance(result.getNewFromBalance());
        to.setBalance(result.getNewToBalance());

        accountRepository.save(from);
        accountRepository.save(to);
    }
}
Enter fullscreen mode Exit fullscreen mode

And the business rules should actually live inside Transfer:

public class Transfer {

    private final BigDecimal fromBalance;
    private final BigDecimal toBalance;
    private final BigDecimal amount;
    private final List<TransferRule> rules;

    public Transfer(BigDecimal fromBalance,
                    BigDecimal toBalance,
                    BigDecimal amount,
                    List<TransferRule> rules) {
        this.fromBalance = fromBalance;
        this.toBalance = toBalance;
        this.amount = amount;
        this.rules = rules;
    }

    public TransferResult execute() {
        if (fromBalance.compareTo(amount) < 0) {
            throw new IllegalArgumentException("Insufficient balance");
        }

        BigDecimal totalFee = BigDecimal.ZERO;
        for (TransferRule rule : rules) {
            totalFee = totalFee.add(rule.fee(fromBalance, toBalance, amount));
        }

        BigDecimal totalDebit = amount.add(totalFee);
        if (fromBalance.compareTo(totalDebit) < 0) {
            throw new IllegalArgumentException("Insufficient balance for transfer and fee");
        }

        BigDecimal newFromBalance = fromBalance.subtract(totalDebit);
        BigDecimal newToBalance = toBalance.add(amount);

        return new TransferResult(newFromBalance, newToBalance, amount, totalFee);
    }
}
Enter fullscreen mode Exit fullscreen mode

What matters here is not elegance for its own sake. What matters is that responsibility is finally correct:

  1. TransferService no longer owns the transfer rules
  2. Transfer truly owns the transfer business
  3. The database only stores the result

The proper shape of borrowing

Borrowing follows the same pattern.

Once borrowing is recognized as the business object, the right flow becomes:

  1. Service queries user, book, and borrowing history
  2. The data is handed to the borrowing business object
  3. The business result is produced
  4. The result is persisted
public class BorrowService {

    private final UserRepository userRepository;
    private final BookRepository bookRepository;
    private final BorrowRecordRepository borrowRecordRepository;
    private final BorrowChecker borrowChecker;
    private final DueDateCalculator dueDateCalculator;

    public BorrowRecord borrow(String userId, String isbn) {
        User user = userRepository.findById(userId);
        Book book = bookRepository.findById(isbn);
        boolean hasOverdue = borrowRecordRepository.hasOverdueBooks(userId);
        boolean hasBorrowedSameBook = borrowRecordRepository.hasBorrowedBook(userId, isbn);

        BorrowCheckResult checkResult = borrowChecker.check(
                user.getCreditScore(),
                user.getCurrentBorrows(),
                hasOverdue,
                hasBorrowedSameBook
        );

        if (!checkResult.canBorrow()) {
            throw new IllegalArgumentException(checkResult.getReason());
        }

        LocalDate borrowDate = LocalDate.now();
        LocalDate dueDate = dueDateCalculator.calculate(borrowDate);

        BorrowRecord record = BorrowRecord.create(
                UUID.randomUUID().toString(),
                userId,
                isbn,
                borrowDate,
                dueDate
        );

        borrowRecordRepository.save(record);
        bookRepository.save(book.decreaseAvailable());
        userRepository.save(user.incrementBorrows());

        return record;
    }
}
Enter fullscreen mode Exit fullscreen mode

The pattern is the same:

  1. Service loads data
  2. Domain calculates
  3. Repository stores the result

Different systems look different only in one decisive place:
what the true business object actually is.

Why services should not call other services

If a service represents one user-visible business operation, then it is effectively a use case, which means an orchestration layer.

In that case, one service calling another service usually means the business boundary is wrong. One operation unit should not need another operation unit to explain itself.

A better structure is:

  1. One service depends directly on the repositories it needs
  2. One service invokes the business objects it needs
  3. One service takes responsibility for one complete business result

That is how responsibilities stay clear.

Why in-memory testability matters

Many systems claim to have a domain layer. But if that layer is only a collection of hollow entities while the real business logic still lives inside services, then the modeling was never completed.

The real difference is not whether the project has a package called domain.

The real difference is whether, after the service loads the data, a real business object takes over, calculates the result, and carries the invariant.

If that is true, then the core rules should be testable in memory without booting a database, Spring, HTTP, or external systems.

That is one of the strongest signs that the business has finally been placed where it belongs.

Conclusion

Real domain modeling is not about looking like DDD. It is about putting the business inside the object.

If the key invariant of a system:

  1. does not live in a business object
  2. cannot run independently from the framework
  3. survives only through service execution order
  4. loses meaning as soon as the services are split

then no matter how complete the terminology looks, the system is still in service mode.

If, instead, a system:

  1. finds the truly indispensable business object
  2. places the invariant on that object
  3. pushes service back to orchestration
  4. makes business rules independently testable in memory

then it is much closer to real domain modeling, even if it looks nothing like mainstream DDD demos.

Here is my demo https://github.com/cypress927/real-business-ddd

Top comments (0)