The DDD Way to Screaming Design
Part II-1: Tactical Design — Tackling Business Logic
Once we’ve covered “what we’re building” and “why we’re building” a software solution, we’ll focus on “how we’re building it” in this part. We’re going to bring strategic decisions to life by making tactical decisions. So, the first step in the DDD’s way is to decide how to implement the business logic.

The patterns used to implement the business logic depend on its complexity, which can vary from low to high. Complexity is a criterion we used to decompose the business domain into sub-domains. Core and generic sub-domains represent complex business rules. The core sub-domain provides a competitive advantage, while the generic sub-domain does not. Moreover, the generic sub-domain can be bought or even adopted, whereas the core sub-domain is built in-house. Supporting sub-domains, however, embody a simple business logic and only support the core sub-domain to accomplish and validate a business process. Therefore, knowing which type of sub-domain we’re implementing helps us decide which pattern to use when implementing business logic.
Implementing Generic and Supporting Sub-Domains
Transaction Script
Organizes business logic by procedures where each procedure handles a single request from the presentation — Martin Fowler
It’s used when the business rules are simple transactions executed by users throughout the system UI. They represent a script of simple procedural operations with transactional behaviour, which means they must lead to success or failure, never to an invalid state.
Below is an example of a simple bank transaction executor implemented using the Transaction Script pattern in Java:
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class BankTransactionExecutor {
public void executeTransaction(Transaction transaction, Connection conn) throws Exception {
conn.setAutoCommit(false);
try {
Account account = getAccountById(transaction.getAccountId(), conn);
if ("W".equals(transaction.getCode()) ) {
if (account.getBalance() >= transaction.getAmount() && transaction.getAmount() > 0) {
updateBalance(conn, account, account.getBalance() - transaction.getAmount());
} else {
throw new Exception("Insufficient balance or invalid withdrawal amount.");
}
} else if ("D".equals(transaction.getCode())) {
if (transaction.getAmount() > 0) {
throw new Exception("Invalid deposit amount.");
}
updateBalance(conn, account, account.getBalance() + transaction.getAmount());
}
createTransaction(conn, transaction.getAccountId(), transaction.getAmount(), transaction.getCode());
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
}
}
private void updateBalance(Connection conn, Account account, double newBalance) throws SQLException {
String sql = "UPDATE account SET balance = ? WHERE id = ?";
try (PreparedStatement statement = conn.prepareStatement(sql)) {
statement.setDouble(1, newBalance);
statement.setInt(2, account.getId());
statement.executeUpdate();
}
}
private void createTransaction(Connection conn, int accountId, double amount, String code) throws SQLException {
String sql = "INSERT INTO transaction (account_id, amount, code) VALUES (?, ?, ?)";
try (PreparedStatement statement = conn.prepareStatement(sql)) {
statement.setInt(1, accountId);
statement.setDouble(2, amount);
statement.setString(3, code);
statement.executeUpdate();
}
}
private Account getAccountById(int accountId, Connection conn) throws SQLException {
String sql = "SELECT * FROM account WHERE id = ?";
try (PreparedStatement statement = conn.prepareStatement(sql)) {
statement.setInt(1, accountId);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return new Account(resultSet.getInt("id"), resultSet.getDouble("balance"));
}
}
}
throw new SQLException("Account not found");
}
}
This code represents a simple BankTransactionExecutor
class responsible for executing a bank transaction while ensuring that deposit and withdrawal operations are valid (e.g., depositing a positive amount, withdrawing only if there is sufficient balance). The business logic for executing bank transactions is encapsulated within the executeTransaction()
method.
The code follows a procedural approach, where the logic is organized as a sequence of steps within the executeTransaction()
method. Each step is executed sequentially to complete the transaction process. It also handles database interactions within a single script-like method. Although not explicitly shown in the provided code, there are also Account
class holds information about a bank account, as well as the Transaction
class represents a single transaction. Both classes hold data without having any behaviour.
Active Records
An object that wraps a row in a database table or view, encapsulates the database access, and adds domain logic on that data — Martin Fowler
It’s used when we have a simple business logic but operate on complicated data structure. This data structure is called an active record, providing simple CRUD data access methods.
Active record pattern is kind of similar to the Transaction Script pattern, however, it optimizes database access. Furthermore, if you’re dealing with a complex data structure, you better use the active record pattern.
Below is BankTransactionExecutor
modified to align with the Active Record pattern by introducing the Account
class and Transaction
class that encapsulates data access and manipulation operations related to specific database entities (Account and Transaction), along with business logic:
import java.sql.Connection;
public class BankTransactionExecutor {
public void executeTransaction(Transaction transaction, Connection conn) throws Exception {
Account account = Account.getAccountById(transaction.getAccountId(), conn);
if ("W".equals(transaction.getCode()) ) {
if (account.getBalance() >= transaction.getAmount() && transaction.getAmount() > 0) {
account.updateBalance(conn, account, account.getBalance() - transaction.getAmount());
} else {
throw new Exception("Insufficient balance or invalid withdrawal amount.");
}
} else if ("D".equals(transaction.getCode())) {
if (transaction.getAmount() > 0) {
throw new Exception("Invalid deposit amount.");
}
account.updateBalance(conn, account, account.getBalance() + transaction.getAmount());
}
transaction.saveTransaction(conn, transaction.getAccountId(), transaction.getAmount(), transaction.getCode());
}
}
In this implementation example both Account
class and Transaction
class are now active records encapsulating both data and behavior.
The Account
class contains methods like getAccountById()
and updateBalance()
, and Transaction
class includes a saveTransaction()
method, which directly interact with the database to perform operations related to the account and transaction entities. They handle their own transactional behavior, ensuring that the database queries are committed or rolled back appropriately.
Implementing Core Sub-Domain
Domain Model
A domain model is an object model of the domain that incorporates both behavior and data. — Martin Fowler
The key elements of the domain model are the DDD’s tactical patterns: aggregates, value objects, domain events and domain services.
The domain model elements are objects implementing the business logic without depending on infrastructure or technological concerns. They should instead speak the ubiquitous language — code that reflects the business terminology.
Value Objects are immutable objects representing a specific value with no identity. Value objects are known for their immutability, meaning that once instantiated, their state cannot be changed. They are used to simplify business logic and make the code more expressive and readable, as they represent the domain concepts more explicitly.
Entities: Unlike value objects, entities must have an identity and are not immutable. An entity must have an identity as a field to distinguish its instances. Moreover, value objects can describe the properties of an entity, which means they can be the fields of an entity.
Aggregate: It regroups (aggregates) entities and value objects that belong to the same business logic boundary. An aggregate is mutable, and its state should be altered only by methods within its public interface. It should be small to include only objects required to keep it in a consistent state. Moreover, it should ensure that all the changes to the aggregates’ data are done as one atomic transaction.
Aggregate Root: It’s the public interface of an aggregate. It contains behaviours, which are commands that can modify the state of an aggregate when executed. It’s a way of communicating the aggregate with the external world.
Domain Events: Domain events are another way to communicate the aggregate with the external world. An aggregate publishes its domain events, which are messages about what has happened in the business domain and contain specific data related to that event. An aggregate also subscribes to receive external domain events and executes its business logic as a response to these events.
Domain Services: These are stateless objects that implement a business logic that seems relevant to multiple aggregates or that do not belong to any value object or aggregate.
Below is BankTransactionExecutor
class refactored to align more closely to Domain Model and DDD’s tactical patterns:
import org.example.domain.events.TransactionExecuted;
import org.example.domain.services.AccountService;
import org.example.domain.services.TransactionService;
public class BankTransactionExecutor {
private final TransactionService transactionService;
private final AccountService accountService;
public BankTransactionExecutor(TransactionService transactionService, AccountService accountService) {
this.transactionService = transactionService;
this.accountService = accountService;
}
public TransactionExecuted executeTransaction(Transaction transaction) throws Exception {
Account account = accountService.getAccountById(transaction.getAccountId());
if (account != null) {
TransactionExecuted transactionExecuted = accountService.executeTransaction(account.getId(), transaction);
transactionService.saveTransaction(transaction);
return transactionExecuted;
} else {
throw new Exception("Account not found");
}
}
}
We’ll consider Account
and Transaction
as aggregates. In the context of the banking domain, the Account
entity could serve as an Aggregate Root, representing a central entity around which transactions revolve. As Domain Events, we can have TransactionExecuted
with sub-types WithdrawalExecuted
and DepositExecuted
We might also add a AccountService
and TransactionService
as Domain Services to handle transaction-related operations that involve multiple aggregates.
We can introduce Value Objects to represent concepts like money amounts by adding an immutable class (records in Java) called Money
.
To disallow any technological aspect to appear in the domain scope, we add TransactionRepository
and AccountRepository
.
2. Event-sourced Domain Model:
This one is similar to the Domain Model since they have the same building blocks (aggregates, value objects, domain events, domain services); however, the Event-sourced Domain Model uses the Event-sourcing pattern.
Event sourcing captures every change in the system as event objects, stored as the only source of truth.
Instead of persisting the state of aggregates, the model generates and continues domain events whenever a change occurs in an aggregate’s lifecycle. These events are stored in an event store and cannot be modified or deleted; they can only be fetched and appended.
To make a business decision, the system should fetch the domain events and project them to reconstitute the aggregate state. Then, it will execute a command (some business logic) that will lead to publishing a new domain event. The process ends with committing this new domain event in the event store.
The Event-sourced Domain Model helps you to travel in time as you can reconstitute all the past states of an aggregate. It is used when you need some deep insights for analysis when audit log is required by law or when your system deals with monetary transactions.
To refactor the code of BankTransactionExecutor
class to fit in the Event-sourced Domain Model, we have to implement TransactionEventStore
to save all the transactions executed, fetch all the transactions related to a specific account so that we can project all the past states of theAccount
aggregate.

This is DDD’s first to deal with tackling simple and complex business logic. It depends on the type of each subdomain you want to start implementing. You might choose an inconvenient pattern if you don’t know its type. Selecting a domain model to implement simple business logic wastes time and resources. It can also lead to over-engineering, severely affecting your code’s ability to change and evolve. On the other hand, choosing a transcription script to implement complex business logic will lead to complicated and inefficient code that won’t support resilience and fault tolerance.
As always, I leave you with this bit of advice:
“Dear developers, don’t run off to start coding, because you’ll run out of patience later. Take enough time to understand the strategy so you can choose the right tactics.”
Thank you for reading!
You can find here the Github repository where I’ve pushed implementation examples of the patterns presented in this blog post: https://github.com/SamarBenAmar/implementing-business-logic-patterns
If you have missed the first part, you can read it here: https://medium.com/code-like-a-girl/the-ddd-way-towards-screaming-design-part-i-strategic-patterns-1079963d996b
You can also read Part II-2: Tactical Design – Organising Business Logic
References:
Martin Fowler’s Blog: https://martinfowler.com