The End of OOP? Exploring the Power of Data Oriented Programming
Introduction to Data Oriented Programming
Since I started learning about Software Engineering, Object Oriented Programming (OOP) has been the dominant paradigm. However, over the past few years, I have been exploring a different paradigm: Data Oriented Programming (DOP). In this post, I will introduce DOP and how it contrasts with OOP. This will be the first of a five-part series where we delve deeper into DOP concepts, principles, and practical applications.
DOP represents a significant mindset shift from OOP, bringing in a lot of new ideas and approaches. Given its depth, I am splitting this topic into five posts. This first post provides a general overview of DOP. In the subsequent posts, we will explore specific concepts with examples in Clojure and Java. Although examples will be in these languages, DOP principles are applicable across various programming languages.
In the following four posts, I will focus on explaining each of the four principles stated by Brian Goetz in his post “Data Oriented Programming in Java”:
Model the data, the whole data, and nothing but the data.
Data is immutable.
Validate at the boundary.
Make illegal states unrepresentable.
Example: Banking System User Stories
To understand DOP better, let’s first consider how we would design a banking system using OOP, focusing on these user stories. Please note that the following user stories and proposed solution are not intended to be the best or most critical analysis of OOP but are used here for illustrative purposes.
User Story 1: Create Freemium Customers
As a bank, I want to create freemium customers so that they can open savings accounts only.
Acceptance Criteria:
Freemium customers can only have savings accounts.
Freemium customers have a name, address, and unique ID.
User Story 2: Create Premium Customers
As a bank, I want to create premium customers so that they can open both savings and checking accounts.
Acceptance Criteria:
Premium customers can have both savings accounts and checking accounts.
Premium customers have a name, address, and unique ID.
User Story 3: Deposit Money
As a customer, I want to deposit money into my account so that I can save or spend it later.
Acceptance Criteria:
Customers can deposit money into their accounts.
The deposited amount is added to the account balance.
User Story 4: Withdraw Money
As a customer, I want to withdraw money from my account so that I can access my funds.
Acceptance Criteria:
Customers can withdraw money from their accounts up to the available balance.
For checking accounts, withdrawals can exceed the balance up to the overdraft limit.
User Story 5: Freemium Customer Savings Account
As a freemium customer, I want to open a savings account so that I can earn interest on my deposits.
Acceptance Criteria:
Freemium customers can create savings accounts with an interest rate.
Savings accounts can calculate and apply interest to the balance.
User Story 6: Premium Customer Checking Account
As a premium customer, I want to open a checking account so that I can have access to overdraft protection.
Acceptance Criteria:
Premium customers can create checking accounts with an overdraft limit.
Checking accounts allow withdrawals up to the balance plus overdraft limit.
User Story 7: Premium Customer Savings Account
As a premium customer, I want to open a savings account so that I can earn interest on my deposits.
Acceptance Criteria:
Premium customers can create savings accounts with an interest rate.
Savings accounts can calculate and apply interest to the balance.
User Story 8: Interest Application for Savings Account
As a savings account holder, I want my account to calculate and apply interest periodically so that my savings grow over time.
Acceptance Criteria:
Savings accounts can calculate interest based on the current balance and interest rate.
Savings accounts can apply the calculated interest to the balance.
User Story 9: Enforce Account Restrictions
As a bank, I want to ensure that customers can only open accounts they are eligible for so that account restrictions are enforced.
Acceptance Criteria:
Freemium customers can only open savings accounts.
Premium customers can open both savings and checking accounts.
User Story 10: Search for Account Owners
As a bank manager, I want to find the names of all account owners so that I can have an overview of who holds accounts in the bank.
Acceptance Criteria:
The bank can search through all accounts.
The bank can return the names of all account owners.
OOP Proposed Solution
These user stories can be addressed through an Object Oriented Design. Below is a proposed class diagram for this solution.
Implementation
The Bank
class represents the data, behavior, and validations required to support the requirements listed above. The Customer
class is abstract because we can have two types of customers: Freemium and Premium. These are implemented by the FreemiumCustomer
and PremiumCustomer
classes, which both implement the canHaveAccount
method to determine if a new account can be created for that user. Remember, Freemium customers can only get Savings accounts, while Premium customers can get both Checking and Savings accounts.
The Bank
class has two lists: one for storing all customers and the other for storing and managing bank accounts. The Account
class is intended to represent all common attributes and behaviors of any account, such as the balance, owner, and methods for depositing money.
To keep the example simple and focused on illustrating the core concepts, we intentionally omit getters and setters (encapsulation) in the code. Here is the implementation in Java:
import java.util.ArrayList;
import java.util.List;
public class Bank {
String name;
List<Customer> customers;
List<Account> accounts;
public Bank(String name) {
this.name = name;
this.customers = new ArrayList<>();
this.accounts = new ArrayList<>();
}
public void addCustomer(Customer customer) {
customers.add(customer);
}
public void createAccount(Customer customer, Account account) {
if (customer.canHaveAccount(account)) {
accounts.add(account);
account.ownerId = customer.id;
customer.addAccount(account.id);
}
}
public List<String> findAllAccountOwners() {
List<String> accountOwners = new ArrayList<>();
for (Account account : accounts) {
for (Customer customer : customers) {
if (customer.id.equals(account.ownerId)) {
accountOwners.add(customer.name);
}
}
}
return accountOwners;
}
}
abstract class Customer {
String id;
String name;
String address;
List<String> accountIds;
public Customer(String id, String name, String address) {
this.id = id;
this.name = name;
this.address = address;
this.accountIds = new ArrayList<>();
}
public abstract boolean canHaveAccount(Account account);
public void addAccount(String accountId) {
if (canHaveAccount(accountId)) {
accountIds.add(accountId);
}
}
}
class FreemiumCustomer extends Customer {
public FreemiumCustomer(String id, String name, String address) {
super(id, name, address);
}
@Override
public boolean canHaveAccount(Account account) {
return account instanceof SavingsAccount;
}
}
class PremiumCustomer extends Customer {
public PremiumCustomer(String id, String name, String address) {
super(id, name, address);
}
@Override
public boolean canHaveAccount(Account account) {
return account instanceof SavingsAccount || account instanceof CheckingAccount;
}
}
abstract class Account {
String id;
double balance;
String ownerId;
public Account(String id) {
this.id = id;
this.balance = 0;
}
public void deposit(double amount) {
if (amount > 0) {
this.balance += amount;
}
}
public void withdraw(double amount) {
if (amount > 0 && amount <= this.balance) {
this.balance -= amount;
}
}
}
class SavingsAccount extends Account {
double interestRate;
public SavingsAccount(String id, double interestRate) {
super(id);
this.interestRate = interestRate;
}
public void applyInterestRate() {
this.deposit(interestRate * this.balance);
}
}
class CheckingAccount extends Account {
double overdraftLimit;
public CheckingAccount(String id, double overdraftLimit) {
super(id);
this.overdraftLimit = overdraftLimit;
}
@Override
public void withdraw(double amount) {
double balanceAvailable = this.balance + this.overdraftLimit;
if (amount > 0 && amount <= balanceAvailable) {
this.balance -= amount;
}
}
}
Although this approach with OOP works fine it is complex to maintain, this complexity is led by:
Behaviors and data structures are mixed
Validations and behaviors are constraints by inheritance (which is the most coupled relationship)
Unpredictable behavior in concurrency environments, since its mutability
Since the behaviors are stateful the tests are harder
DOP Proposed Solution
DOP emphasizes modeling data as data and separating it from behaviors, making behaviors stateless. The following class diagram illustrates the data model for this approach:
As you can see, BankData
has four fields: customersById
, savingsAccountsById
, checkingAccountsById
, and name
. The first three fields are maps, which act as dictionaries or indexes, facilitating efficient searching. Essentially, BankData
is also a map, but depending on the programming language chosen, we can construct our data model accordingly.
In Clojure, which is a functional programming language adhering to many DOP principles like immutability, we can implement our model as follows:
(ns bank-data)
(def bank-data
{:name ""
:customersById {}
:savingAccountsById {}
:checkingAccountsById {}})
(defn create-customer
[id name address isFreemium isPremium]
{:id id
:name name
:address address
:savingsAccountIds []
:checkingAccountIds []
:isFreemium isFreemium
:isPremium isPremium})
(defn create-savings-account
[id owner-id interest-rate]
{:id id
:balance 0.0
:ownerId owner-id
:interestRate interest-rate})
(defn create-checking-account
[id owner-id overdraft-limit]
{:id id
:balance 0.0
:ownerId owner-id
:overdraftLimit overdraft-limit})
In Java, although primarily an OOP language, we can also implement our DOP model. Java has introduced new features in recent years to support DOP in a type-safe manner, such as records, sealed classes, pattern matching, lambdas, etc. Here’s the Java implementation of our design:
import java.util.Map;
import java.util.List;
record BankData(
String name,
Map<String, CustomerData> customersById,
Map<String, SavingsAccountData> savingAccountsById,
Map<String, CheckingAccountData> checkingAccountsById
) {}
record CustomerData(
String id,
String name,
String address,
List<String> savingsAccountIds,
List<String> checkingAccountIds,
boolean isFreemium,
boolean isPremium
) {}
record SavingsAccountData(
String id,
double balance,
String ownerId,
double interestRate
) {}
record CheckingAccountData(
String id,
double balance,
String ownerId,
double overdraftLimit
) {}
On the other hand, we can model our behavior as stateless functions, which improves the testability of our code. The following diagram shows a first version of this solution with DOP:
Keep in mind that in the methods' parameters, the first parameter is always the dataset required to execute the process, ensuring that every function is stateless. Let’s describe each class in more detail:
BankLogic:
Centralizes operations related to the
BankData
.Includes methods for adding customers, creating accounts, finding account owners, and performing deposits and withdrawals.
SavingsAccountLogic:
Handles specific operations related to savings accounts.
Includes methods for applying interest rates, withdrawing, and depositing money into savings accounts.
CheckingAccountLogic:
Manages operations for checking accounts.
Contains methods for withdrawals and deposits specific to checking accounts.
CustomerLogic:
Provides methods to check if a customer can have a specific type of account.
Ensures that the correct business rules are applied when handling customer-related operations.
Notice that we no longer have inheritance relationships. We will discuss more about relationships in DOP approaches in future posts. This approach provides the following benefits:
Separation of Concerns:
Data and behavior are separated, making the system more modular and easier to maintain.
Changes in business logic do not affect the data structures and vice versa.
Reusability:
Logic classes can be reused across different parts of the application without duplication.
Common operations are centralized, reducing the risk of inconsistent behavior.
Testability:
Functions operating on plain data structures are easier to test in isolation.
Mocking data for unit tests is straightforward, and behavior can be verified independently of data structure changes.
I want to share a possible implementation of these behavior classes with you, but I believe it is a lot of information to digest at once. Therefore, we will go through it step by step in future posts to make it easier to understand.
Conclusion
This post has introduced a Data Oriented Programming (DOP) approach to implementing a banking system based on the given user stories. The separation of data and behavior enhances modularity, reusability, and testability. Immutability reduces errors in our system and improves concurrency capabilities. You might wonder how data can be immutable when we need to add data or deposit money into accounts, etc. It's similar to how git repositories work, and I will demonstrate this in the next posts.
In the upcoming posts, we will delve deeper into this example, exploring each aspect of Data Oriented Programming in more detail. We will examine the principles of DOP, how to implement them in Java, and how to handle complex scenarios within this paradigm. Stay tuned as we continue to unfold the power of Data Oriented Programming and its practical applications in software development. Please let me know if you have any questions or contributions to this discussion.