Skip to content
ArticlesBack EndJava

DDD for CRUD Developers: When and Why to Level Up | Java Guide

13 min readsalahtobok
DDD for CRUD Developers Hero

DDD for CRUD Developers: When and Why to Level Up

1. You’ve been building CRUD apps for years – and that’s OK

Most developers start their careers with CRUD. CRUD stands for Create, Read, Update, and Delete. It is simple, fast, and works very well for many software applications. If you are building a simple blog, a basic address book, or a static content website, CRUD is the best choice.

In a traditional CRUD app, the database is the most important part of the entire system. You almost always design the system by creating database tables first. You ask yourself, “What tables do I need?”. You draw lines between tables to show relationships.

Then, you write Java objects to map to those tables using tools like Hibernate. Finally, you write simple service classes to save or read the data. This process is very fast. You can build a working application in just a few days.

But software changes over time. Businesses grow and requirements change. What happens when your simple system gets very complex business rules? What happens when a “save” operation requires checking ten different rules, talking to an external API, and updating three different tables? That is when CRUD becomes very hard to manage.

When the business logic becomes complicated, CRUD starts to slow you down. The simple services become huge, messy files. Changing one small thing breaks another thing. This is a normal part of software evolution, but you need a new tool to handle it.

2. When CRUD stops working (signs your app needs DDD)

You might need Domain-Driven Design (DDD) if you see these clear warning signs in your codebase.

  • God Classes: Your OrderService has 3,000 lines of code. It does everything. It checks inventory, charges credit cards, sends emails, and saves to the database. It is impossible to read or test easily.
  • Anemic Domain Models: Your Java entities only have getters and setters. They look exactly like database tables. They do not have any real business logic inside them. They are just empty bags of data.
  • Feature Fear: When you change one feature, three other unrelated features break. Developers become afraid to touch old code. Every new feature takes twice as long as the last one.
  • Translation Problems: The business experts talk about a “Cart Validation Process,” but you only have a UserTransactionUpdate method in the code. The developers and the business experts do not speak the same language.

If this sounds like your current project, you need a better architecture. You need a way to organize code so that it matches the real business rules. This is exactly what DDD does.

3. The complexity threshold: simple vs. complex domains

Complexity Threshold
Complexity Threshold Graph: DDD pays off when complexity is high

Not every project needs DDD. In fact, using DDD for a simple project is a bad idea. DDD takes more time at the start. It requires more files, more layers, and deep, long talks with business experts.

Look at the complexity threshold graph. On the left side, the business rules are simple. CRUD is much faster here. If your project is a basic data-entry tool, stick with CRUD. You do not need DDD to save a user’s name and email to a database.

However, look at the right side of the graph. If your project manages complex money rules, health records, shipping logistics, or taxes, the complexity will grow fast. In these complex domains, CRUD becomes a nightmare. DDD saves you from writing spaghetti code because it focuses on the business rules first.

You have to decide where your project lives on this graph. If the logic is heavy and the rules change often, you have crossed the threshold. It is time to level up to DDD.

4. What changes when you switch to DDD? (mental model shift)

The biggest change when moving to DDD is how you think about your code. In CRUD, you think about the database first. You think about columns, rows, and foreign keys. You think about the state of the data.

In DDD, you think about the behavior and the business rules first. You stop caring about the database at the beginning. The database is just a detail. It is just a place to store things later.

You stop asking “What tables do I need?”. You start asking “What can this object do?”. You ask the business experts how things work. You write code that reads like English.

For example, in CRUD, a developer might say, “I need to update the status column to 2 in the orders table.” In DDD, a developer says, “I need to ship the order.” The business logic moves out of the big service classes and goes directly into the small domain objects.

5. CRUD patterns you already know (and their DDD equivalents)

CRUD to DDD Mapping
Mapping CRUD concepts to DDD patterns

DDD introduces many new words. This can be scary at first. But many of these concepts are very similar to things you already know as a CRUD developer.

  • Database Tables -> Aggregates: In CRUD, you save tables. In DDD, an Aggregate is a group of related objects (like an Order and OrderItems) treated as one single unit. You save the whole Aggregate together.
  • Utility Classes -> Domain Services: When a business rule does not belong to just one entity, you do not put it in a generic Utility class. It goes into a Domain Service that handles that specific process.
  • Primitives (String, int) -> Value Objects: In CRUD, you use a simple `String` for an email address. In DDD, you create an EmailAddress object. This object validates itself. It guarantees that the string inside is always a real email address.
  • Database Triggers -> Domain Events: Instead of using database triggers to do things after a row is saved, DDD uses Domain Events. When an order is shipped, the code publishes an `OrderShippedEvent`. Other parts of the code listen for this event.

6. Your first refactoring: move logic from Service to Entity

Let us look at a real Java 25 example using Spring Boot 4. This is a very common CRUD mistake. The service does all the work, and the entity is just dumb data.


// BAD CRUD: Logic in the service. The entity is anemic (data only).
@Service
public class OrderService {
    public void addDiscount(Order order, BigDecimal amount) {
        // The service checks the rules
        if (order.getStatus() == OrderStatus.SHIPPED) {
            throw new RuntimeException("Cannot discount shipped order");
        }
        // The service changes the data directly
        order.setTotalAmount(order.getTotalAmount().subtract(amount));
    }
}

Why is this bad? Because any other service can also call `order.setTotalAmount()`. Another developer might forget to check if the order is shipped. This creates bugs. The data is not safe.

Refactoring Steps

Now, let us fix it using a DDD approach. We move the behavior inside the Order entity. We remove the public setters. The entity protects its own data.


// GOOD DDD: The entity protects its own rules and data.
@Entity
public class Order {
    private BigDecimal totalAmount;
    private OrderStatus status;

    // The logic lives inside the entity now.
    // There are no public setters!
    public void applyDiscount(BigDecimal discount) {
        if (this.status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("Cannot discount a shipped order");
        }
        if (discount.compareTo(this.totalAmount) > 0) {
            throw new IllegalArgumentException("Discount is larger than total");
        }
        this.totalAmount = this.totalAmount.subtract(discount);
    }
}

This is much better. The `Order` object is smart. It controls its own state. No service can force it into a bad state. It is impossible to discount a shipped order now, no matter where the code is called from.

7. Introducing Value Objects into existing CRUD code

In CRUD, we use basic primitive types everywhere. We use `String` for emails, `BigDecimal` for money, and `int` for quantities. But this can cause silent bugs. For example, you might accidentally pass a `UserId` string into a method that expects a `ProductId` string. The compiler will not complain.

Value Objects fix this problem completely. A Value Object is a small object that has no unique ID. Its data defines it. Two Value Objects with the same data are exactly the same. In Java 25, we use the record keyword to build them very easily.


// Using a record for a Value Object in Java 25
public record Money(BigDecimal amount, String currency) {
    
    // Validation happens when the object is created
    public Money {
        if (amount == null || currency == null) {
            throw new IllegalArgumentException("Amount and currency cannot be null");
        }
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Money cannot be negative");
        }
    }
    
    // Behavior is attached to the object
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

Now, instead of passing a `BigDecimal` around, you pass a `Money` object. The `Money` object guarantees that it is never negative. It guarantees that you never accidentally add Dollars and Euros together. You do not need to write validation checks in your services anymore. The Value Object handles it.

8. Adding Aggregate boundaries without rewriting everything

An Aggregate is a group of entities that change together. One entity acts as the "root" (the boss). You should only interact with the root. For example, an `Order` is a root. The `OrderItems` are the children.

In a bad CRUD app, you might have an `OrderItemService` that deletes an item directly from the database. This is dangerous. If you delete an item, the `Order` total amount is now wrong! The `Order` does not know the item was deleted.

In DDD, you must go through the root. You tell the `Order` to remove the item. The `Order` removes it and then updates its own total amount at the same time. The data stays consistent.

You can start using Aggregates slowly in your current CRUD app. You do not have to rewrite everything today. Pick one complex part of your system. Define the root entity. Stop letting your Spring services modify the child items directly. Force all changes to go through the root entity's methods.

9. Real-world case study: from CRUD to DDD (before/after)

Let us look at a deep real-world example. Imagine an e-commerce checkout process. In the old CRUD app, the CheckoutService was a massive God Class. It was 2,000 lines long. Let us look at what it did.

First, it checked inventory for every item. Then, it called a payment gateway to charge the credit card. After that, it updated the order status in the database. Finally, it used an SMTP library to send an email to the user. If the email failed, the whole database transaction rolled back, and the user lost their order!

This is a classic CRUD disaster. It is tightly coupled and impossible to test.

With DDD, we refactored the entire process. The CheckoutService is now a simple Application Service. It simply coordinates the flow. It does not contain any business rules.

We created an `Order` Aggregate. The `Order` checks all the business rules. It calculates the final price and applies discounts. When the payment is successful, the `Order` creates a `Domain Event` called `OrderPaidEvent`.

We use Spring Events to listen for the `OrderPaidEvent`. A completely separate listener class catches the event and sends the email. If the email fails, it does not roll back the database transaction. The order is safe.

The code is much cleaner. It is split into small, logical pieces. The business rules are easy to read and easy to test without needing a real database or a real SMTP server.

10. The progressive migration roadmap

Architecture Evolution
How to evolve your CRUD architecture step-by-step

Do not try to rewrite your whole application in one weekend. That is a guaranteed way to fail. Moving to DDD is a journey. Follow this progressive path to evolve your code safely:

  1. Fix Your Language: Start talking to business experts. Fix your naming. If they say "Customer", do not name your class "User". Create a Ubiquitous Language.
  2. Create Value Objects: Find places where you use `String` or `int` for important concepts. Create small Value Objects for things like Money, Addresses, and Phone Numbers.
  3. Push Logic Down: Find business rules hiding in your `@Service` classes. Move those rules into the `@Entity` classes. Remove public setters from the entities.
  4. Define Aggregates: Draw boundaries around related entities. Pick a root. Stop updating child entities directly.
  5. Extract Domain Services: For complex rules that involve multiple Aggregates, create specific Domain Services to handle them.

By taking small steps, you can improve your codebase every single week without breaking existing features.

11. Best practices for CRUD-to-DDD evolution

Keep your Domain layer perfectly clean. The Domain layer is the heart of your application. It contains the Entities, Value Objects, and Domain Rules.

Do not put Spring annotations like `@Autowired` inside your Domain entities. Do not put database queries or JSON parsers in there either. Let the Infrastructure layer handle the database and the web server. Your Domain code should only be pure Java.

Use Testcontainers for your integration tests. When you refactor from CRUD to DDD, you want to be completely sure you do not break the database saving process. Testcontainers lets you spin up a real PostgreSQL or MySQL database in Docker. You can verify that your new rich DDD objects save to the database correctly without data loss.

Conclusion

Moving from CRUD to DDD is a big step for a Java developer. It is a completely different way to think about software design. It feels strange at first to remove logic from your Spring Services and put it into objects. You will make mistakes along the way.

But when your business rules get highly complex, DDD is the best tool you have. It makes your code safe, testable, and deeply connected to real business goals. It stops the spaghetti code and the God Classes.

Start small today. Pick one complex part of your application. Refactor one entity at a time. Create your first Value Object. Watch your code quality improve.

Companion Code Workshop

You can see all the code examples in action in our public repository. We have prepared a complete Spring Boot 4 companion project demonstrating the shift from CRUD to DDD.

Check out the code here: DDD for CRUD Developers Workshop Code.

FAQ

Is DDD only for microservices?

No. You can and should use DDD in a monolith. Good modular monoliths rely heavily on DDD boundaries. Microservices are just one way to deploy code. DDD is a way to design code.

Do I need to stop using Spring Data JPA?

No. You can still use Hibernate and Spring Data JPA 4. But you must map your entities carefully. They must stay focused on behavior, not just database columns. Use private setters and custom methods.

Can I mix CRUD and DDD?

Yes. This is a very common pattern called CQRS (Command Query Responsibility Segregation). You can use complex, rich DDD models for writing and validating data. Then, you can use fast, simple CRUD queries for reading data to show on the screen.

Comments