Skip to content
ArticlesBack EndJava

DDD vs CRUD: Visual Guide for Java Developers | When to Use Which

18 min readsalahtobok
Hero image for DDD vs CRUD visual guide

1. Introduction: The Story of a Broken Backend

Imagine this common scenario. Meet Alex. Alex is a talented Java software engineer who just joined a fast-growing e-commerce company. On the first day, Alex reviews the codebase. The design feels friendly and familiar. It is built using standard CRUD (Create, Read, Update, Delete) patterns.

There is a CustomerEntity, an OrderEntity, and a ProductEntity. Each database entity has an associated Controller, Service, and Spring Data Repository. Everything looks simple, clean, and fast. Alex feels confident.

One week later, the product owner asks for a new business rule: “Never allow a customer to place an order if they have three unpaid invoices.” Alex opens the OrderService, adds an if check, and commits the code.

The following month, the business introduces another rule: “Apply a 10% discount to the total amount if the customer orders more than five items, but only if none of those items are already on clearance sale.” Alex opens OrderService again and adds more nested conditional logic.

One year later, the OrderService has ballooned to over 3,000 lines of spaghetti code. It is full of hidden business rules, defensive null checks, and fragmented calculations. Whenever a developer modifies one line of code, two unrelated production features break. The simple CRUD application has degenerated into a classic “Big Ball of Mud.”

This is a widespread story in software engineering. CRUD is fantastic for simple data entry. But it breaks down rapidly when business logic evolves and multiplies. This is where Domain-Driven Design (DDD) changes the game. In this visual guide, we compare CRUD and DDD side-by-side using modern Java 25 and Spring Boot 4. You will learn exactly when to use CRUD and when to use DDD.

Key Insight: CRUD treats software as a database window. Domain-Driven Design treats software as a business domain simulation. Choose the right tool based on the complexity of your business rules, not technical habit.

2. The CRUD Trap: Why Simple Apps Become Complex Nightmares

CRUD works remarkably well when your application functions primarily as a digital filing cabinet. If your backend simply receives JSON payloads, persists rows to PostgreSQL, and reads them back to a frontend grid, CRUD is the fastest, most cost-effective choice.

The “CRUD trap” happens when real-world business constraints enter the system. In traditional CRUD architectures, data models are “dumb” passive data bags. They contain only fields, getters, and setters. Because entities have no defensive behavior, every business validation rule must live inside external service classes.

Over time, these service classes expand uncontrollably. Different developers write duplicate validation checks across different controllers, batch jobs, and background workers. When a business rule changes, you must locate every service that touches that entity. Missing just one place causes database corruption.

Architecture Comparison: Traditional CRUD vs Domain-Driven Design
Figure 1: Architectural comparison showing database-centric 3-tier CRUD vs domain-centric layered DDD architecture.

3. Side-by-Side Code: Order Creation in CRUD vs. DDD

Let us examine a concrete, runnable example using Java 25. We want to implement an order placement and fulfillment workflow.

The Traditional CRUD Implementation

In standard CRUD, the entity is an Anemic Domain Model. It has open public getters and setters for all attributes. Any class in your application can overwrite its state:

// src/main/java/com/webcodein/workshop/crud/OrderEntity.java
package com.webcodein.workshop.crud;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;

@Entity
@Table(name = "crud_orders")
public class OrderEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String customerEmail;
    private BigDecimal totalAmount;
    private String status; // Raw string: "CREATED", "FULFILLED", "CANCELLED", etc.

    public OrderEntity() {}

    // Public getters and setters allow any service to mutate data arbitrarily
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getCustomerEmail() { return customerEmail; }
    public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}

Because the entity cannot protect itself, the OrderService must validate every single state transition:

// src/main/java/com/webcodein/workshop/crud/OrderService.java
package com.webcodein.workshop.crud;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.NoSuchElementException;

@Service
@Transactional
public class OrderService {

    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public OrderEntity createOrder(String email, BigDecimal amount) {
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Invalid email format");
        }
        if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Amount must be greater than zero");
        }

        OrderEntity order = new OrderEntity();
        order.setCustomerEmail(email);
        order.setTotalAmount(amount);
        order.setStatus("CREATED");

        return repository.save(order);
    }

    public void fulfillOrder(Long id) {
        OrderEntity order = repository.findById(id)
                .orElseThrow(() -> new NoSuchElementException("Order not found: " + id));

        // Procedural guard check in service
        if (!"CREATED".equals(order.getStatus())) {
            throw new IllegalStateException("You cannot fulfill this order because it is not CREATED.");
        }
        order.setStatus("FULFILLED");
        repository.save(order);
    }
}

What makes this dangerous? Look at what any junior developer can write elsewhere in your system:

// Anywhere in the application:
OrderEntity order = repository.findById(1L).get();
order.setStatus("MAGIC_PROCESSED"); // Bypasses all business rules!
order.setTotalAmount(BigDecimal.valueOf(-9999.00)); // Negative total saved to database!
repository.save(order);

The entity is defenseless. It cannot prevent invalid or corrupt states from being stored in your database.

The Domain-Driven Design Implementation

In Domain-Driven Design, the Order class is a Rich Aggregate Root. It protects its internal state through strong encapsulation. It has no public setters. External layers can only request explicit business actions:

// src/main/java/com/webcodein/workshop/ddd/domain/Order.java
package com.webcodein.workshop.ddd.domain;

import java.math.BigDecimal;
import java.util.Objects;

public class Order {

    private Long id;
    private final Email customerEmail;
    private final Money totalAmount;
    private OrderStatus status;

    public Order(Email customerEmail, Money totalAmount) {
        if (customerEmail == null) {
            throw new IllegalArgumentException("Customer email is required");
        }
        if (totalAmount == null || totalAmount.amount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Total amount must be greater than zero");
        }
        this.customerEmail = customerEmail;
        this.totalAmount = totalAmount;
        this.status = OrderStatus.CREATED;
    }

    public static Order reconstitute(Long id, Email customerEmail, Money totalAmount, OrderStatus status) {
        Order order = new Order(customerEmail, totalAmount);
        order.id = id;
        order.status = status;
        return order;
    }

    // Explicit business behavior protecting state transition invariants
    public void fulfill() {
        if (this.status != OrderStatus.CREATED && this.status != OrderStatus.PAID) {
            throw new IllegalStateException("Only CREATED or PAID orders can be fulfilled (current status: " + this.status + ")");
        }
        this.status = OrderStatus.FULFILLED;
    }

    public void cancel(String reason) {
        if (this.status == OrderStatus.FULFILLED) {
            throw new IllegalStateException("Cannot cancel an order that has already been fulfilled");
        }
        if (this.status == OrderStatus.CANCELLED) {
            throw new IllegalStateException("Order is already cancelled");
        }
        if (reason == null || reason.isBlank()) {
            throw new IllegalArgumentException("Cancellation reason is required");
        }
        this.status = OrderStatus.CANCELLED;
    }

    // Pure immutable getters: zero setters
    public Long getId() { return id; }
    public Email getCustomerEmail() { return customerEmail; }
    public Money getTotalAmount() { return totalAmount; }
    public OrderStatus getStatus() { return status; }
}

Now look at how clean and focused the Application Service becomes:

// src/main/java/com/webcodein/workshop/ddd/application/OrderApplicationService.java
package com.webcodein.workshop.ddd.application;

import com.webcodein.workshop.ddd.domain.Email;
import com.webcodein.workshop.ddd.domain.Money;
import com.webcodein.workshop.ddd.domain.Order;
import com.webcodein.workshop.ddd.domain.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.NoSuchElementException;

@Service
@Transactional
public class OrderApplicationService {

    private final OrderRepository repository;

    public OrderApplicationService(OrderRepository repository) {
        this.repository = repository;
    }

    public Long createOrder(CreateOrderCommand command) {
        Email email = new Email(command.email());
        Money money = Money.of(command.amount(), command.currency());
        Order order = new Order(email, money);
        Order saved = repository.save(order);
        return saved.getId();
    }

    public void fulfillOrder(Long id) {
        Order order = repository.findById(id)
                .orElseThrow(() -> new NoSuchElementException("Order not found: " + id));
        order.fulfill(); // Aggregate enforces its own invariant!
        repository.save(order);
    }
}

In this DDD implementation, it is physically impossible to construct an invalid order. It is impossible to bypass business rules. The invariants are permanently guarded inside the domain model.

4. Where Does Business Logic Live?

The primary architectural divergence between CRUD and DDD is the physical location and ownership of business rules.

  • In CRUD: Business logic lives inside the Service Layer. Entities are passive data structures. Services coordinate SQL queries, execute calculations, and mutate fields.
  • In DDD: Business logic lives at the absolute core, inside the Domain Layer. The Application Layer acts as a thin orchestrator. It manages database transactions and dispatches tasks to rich aggregate roots.
Software Layer Responsibility Comparison: CRUD vs DDD
Figure 2: Software layer responsibilities: 3-tier CRUD where services hold all logic vs DDD where the domain is independent and protected.

To dive deeper into domain layering, explore our companion masterclass on DDD Layers Explained: Where Does Each Piece of Code Go?.

5. Anemic Domain Model: The #1 Beginner Mistake

When Java developers transition from university courses or basic tutorials to enterprise Spring Boot, they often place Lombok annotations like @Data or @Setter on JPA entity classes. This practice inadvertently produces an Anemic Domain Model.

Martin Fowler coined the term “Anemic Domain Model” to describe an architectural anti-pattern. An anemic object has the structure of a domain model (classes, relations, properties), but zero business behavior. It contains data with no muscle.

Anemic Domain Model vs Rich Domain Model Code Pattern Comparison
Figure 3: Code pattern comparison: Anemic Domain Model exposing raw setters vs Rich Domain Model encapsulating domain behavior.

A Rich Domain Model binds data and behavior together. You cannot arbitrarily change fields from the outside. You must invoke meaningful business operations (such as order.fulfill() or order.cancel(reason)). This pattern guarantees that entities are always valid, fully encapsulated, and self-documenting.

6. Value Objects: Why String Email is Dangerous

In standard CRUD applications, developers rely heavily on Java primitive types: String, Long, BigDecimal. A customer email is a String. A monetary price is a BigDecimal. A zip code is a String. This anti-pattern is called Primitive Obsession.

Why is primitive obsession hazardous in production? Because a String has infinite possibilities. It can be empty, null, “12345”, or 5,000 characters long. If your service method accepts String email, you must validate that string every time you use it. If 20 different services receive that string, validation logic is duplicated 20 times.

Domain-Driven Design eliminates primitive obsession using Value Objects. A Value Object is an immutable object defined entirely by its structural values. In Java 25, records are the ideal mechanism for Value Objects:

// src/main/java/com/webcodein/workshop/ddd/domain/Email.java
package com.webcodein.workshop.ddd.domain;

public record Email(String value) {
    public Email {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Email cannot be empty");
        }
        String trimmed = value.trim();
        if (!trimmed.contains("@") || !trimmed.contains(".")) {
            throw new IllegalArgumentException("Email format is invalid: " + value);
        }
        value = trimmed.toLowerCase();
    }
}
// src/main/java/com/webcodein/workshop/ddd/domain/Money.java
package com.webcodein.workshop.ddd.domain;

import java.math.BigDecimal;
import java.util.Objects;

public record Money(BigDecimal amount, String currency) {
    public Money {
        if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Money amount cannot be negative");
        }
        if (currency == null || currency.trim().length() != 3) {
            throw new IllegalArgumentException("Currency must be a valid 3-letter ISO code");
        }
        currency = currency.trim().toUpperCase();
    }

    public static Money of(BigDecimal amount, String currency) {
        return new Money(amount, currency);
    }

    public static Money usd(BigDecimal amount) {
        return new Money(amount, "USD");
    }

    public Money add(Money other) {
        Objects.requireNonNull(other, "Cannot add null money");
        if (!this.currency.equals(other.currency())) {
            throw new IllegalArgumentException("Cannot add different currencies: " + this.currency + " and " + other.currency());
        }
        return new Money(this.amount.add(other.amount()), this.currency);
    }
}

Notice how Value Objects encapsulate domain logic. The Money record handles currency conversion safety and addition. When you pass Email and Money into your domain entities, you never need to re-validate them. They are guaranteed valid by construction.

7. State Mutation and Invariant Protection Comparison

How do state mutations flow through a system in CRUD versus DDD? Understanding this workflow clarifies why DDD prevents production data corruption.

CRUD vs DDD State Mutation and Invariant Protection Comparison Diagram
Figure 4: State mutation flow: CRUD showing open setters and scattered rules vs DDD Aggregate Root guarding invariants and emitting Domain Events.

In CRUD (left panel):

  1. The web controller directly forwards raw data to a procedural service.
  2. The service invokes raw setters on the entity (e.g., setStatus("PAID"), setTotal(100.0)).
  3. Because any caller can bypass the service and invoke setters directly, partial updates and invalid states leak into the database.

In Domain-Driven Design (right panel):

  1. The presentation layer sends a validated command DTO to the Application Service.
  2. The Application Service loads the Aggregate Root from the repository.
  3. The aggregate executes business methods (e.g., order.confirmPayment(amount)), validating state machine transitions internally.
  4. If valid, the aggregate changes its state and emits a DomainEvent for downstream notification. The repository persists the updated aggregate as a single consistent unit.

8. Database Concurrency: Locking Contention vs Aggregate Boundary

A major benefit of Domain-Driven Design that few beginners appreciate is how it eliminates database locking contention and deadlock risks under high load.

Database Concurrency: Anemic CRUD Locking vs DDD Aggregate Boundary Diagram
Figure 5: Database concurrency: Cross-table pessimistic locking contention in CRUD vs isolated Aggregate transactional boundaries with optimistic locking in DDD.

In traditional CRUD applications, transactions frequently span multiple unrelated tables. A service might query orders, lock rows in inventory, and update customer_accounts within a single long-lived database transaction. When concurrent requests arrive, database threads block each other, causing connection pool exhaustion and deadlocks.

In DDD, the Aggregate Boundary defines the exact scope of a database transaction. A single transaction should only modify one Aggregate Root. We use JPA’s @Version column for lightweight optimistic locking. If two requests attempt to mutate the same aggregate concurrently, the database detects the version mismatch and rejects the conflict immediately without prolonged table locks. Check our detailed guide on Optimistic vs Pessimistic Locking in Spring Boot.

9. Decision Matrix: When to Use CRUD vs. DDD

Choosing between CRUD and DDD is an engineering trade-off. Using DDD for a basic blog or an admin dashboard is wasteful over-engineering. Conversely, using CRUD for a core billing or banking system is a recipe for catastrophic technical debt.

Architectural Decision Flowchart: When to Use CRUD vs When to Use DDD
Figure 6: Decision Flowchart: Systematic questions to help engineering teams decide between CRUD and DDD.

Use this comprehensive comparative matrix when evaluating your architecture:

Evaluation Dimension Traditional CRUD Architecture Domain-Driven Design (DDD)
Domain Complexity Low. Straightforward data storage, retrieval, and administrative display. High. Intricate business calculations, strict invariant rules, and complex lifecycles.
Business Logic Location Scattered across Services, Controllers, and SQL stored procedures. Encapsulated entirely inside Aggregate Roots, Entities, and Value Objects.
State Mutability Open public getters and setters allow any service to mutate internal state. Strictly protected. State changes occur exclusively via explicit business methods.
Invariant Protection Fragmented. Relies on procedural if/else checks across services. Guaranteed by Aggregate Root boundaries upon instantiation and mutation.
Database Concurrency Long multi-table transactions; risk of deadlocks and thread lock contention. Scoped to a single Aggregate Root; uses optimistic locking (@Version) and events.
Framework Coupling High. Data classes are tightly bound to JPA annotations and Spring transactions. Low. Core domain model consists of pure Java POJOs with zero framework dependencies.
Unit Testability Difficult. Requires heavy Spring test contexts, database mocks, and stubbed repositories. Effortless. Core domain rules are tested in sub-milliseconds with pure JUnit 5 unit tests.
Ideal Application Scale Short-term MVPs, prototypes, simple reporting tools, and internal admin panels. Mission-critical core platforms, financial systems, logistics, and healthcare portals.

10. Real-World Use Cases: Where DDD Shines

To see why Domain-Driven Design is indispensable in complex systems, consider these three production case studies:

Use Case 1: Healthcare Clinical Scheduling

A regional healthcare network initially built their doctor scheduling system with CRUD. However, medical compliance rules were vast: doctors could not work more than 12 consecutive hours; mandatory 8-hour rest periods were required between shifts; surgical shifts demanded specific board certifications. In CRUD, the DoctorSchedulingService grew to 5,000 lines, riddled with race conditions where two schedulers booked the same operating room concurrently. Migrating to DDD, the team created a DoctorShift Entity and an OperatingTheater Aggregate Root. Concurrency conflicts dropped by 95%.

Use Case 2: FinTech Multi-Currency Payment Gateway

An international payment gateway allowed cross-border transactions. In CRUD, currencies and exchange rates were stored as raw String and BigDecimal fields. A developer accidentally added a US Dollar amount to a Japanese Yen amount without applying an exchange rate conversion. The database accepted the computation without error, resulting in financial loss. Implementing a Money Value Object made adding differing currencies impossible at compile time, eliminating an entire category of financial bugs.

Use Case 3: Global Logistics and Shipping

A global courier company tracked parcel deliveries. In their legacy CRUD application, an order could accidentally be marked DELIVERED before it was ever SCANNED_AT_HUB. By encapsulating package status inside an Order Aggregate Root with state-machine validation, illegal status transitions were blocked automatically.

11. Migration Path: Evolving from CRUD to DDD

You do not need to throw away your existing codebase and perform a risky full rewrite. You can migrate an existing CRUD system to DDD incrementally:

  1. Isolate the Core Domain: Do not apply DDD to generic features like user authentication or simple settings. Identify the most profitable, complex part of your system (like billing, pricing, or order management) and begin there. Explore our guide on Bounded Contexts in DDD.
  2. Remove Public Setters: Strip @Setter and @Data from your core entity classes. Where compilation errors occur in service classes, replace them with meaningful domain methods (e.g., replace order.setStatus("CANCELLED") with order.cancel(reason)).
  3. Extract Value Objects: Identify primitive fields that belong together or require validation. Replace String email with an Email record, and BigDecimal amount with a Money record.
  4. Separate Domain Models from JPA Entities: Decouple pure domain objects from database tables. Create an infrastructure JPA entity (OrderJpaEntity) and write a repository adapter that maps between domain aggregates and database rows via Order.reconstitute(...).

12. Production Best Practices for Java Developers

Keep these architectural principles in mind when implementing DDD in Java 25 and Spring Boot 4:

  • Speak the Ubiquitous Language: Use the exact terms that business experts use. If the business says “Dispute Transaction”, name your method disputeTransaction(), not updateStatus(3).
  • Keep Application Services Lean: Application services must never contain if/else business calculations. Their only job is loading aggregates, invoking domain methods, and saving changes.
  • Enforce Architectural Rules with ArchUnit: Use automated tests to verify that domain packages never import Spring, Hibernate, or web classes. ArchUnit detects architectural violations during the build.
  • Never Return Domain Entities from Controllers: Expose immutable DTO records from REST controllers to keep internal domain models private and decoupled from public API contracts.

13. Conclusion and Companion Code

CRUD is an excellent, lightweight approach for straightforward data entry applications. However, as business requirements multiply, CRUD inevitably breeds fragile, unmaintainable code. Domain-Driven Design provides the structured remedy: moving business logic back to where it belongs, safely encapsulated inside Rich Domain Models and Value Objects.

The companion workshop code for this visual guide is available on our companion GitHub repository. It contains the complete side-by-side implementation of both architectures with 43 automated tests and ArchUnit rules.

To continue mastering enterprise software architecture, explore our related guides:

14. Frequently Asked Questions

Is Domain-Driven Design objectively superior to CRUD?

No architecture is universally superior. CRUD is faster to build and simpler to maintain for basic data-entry tools, administrative dashboards, and prototypes. DDD is far superior for strategic applications with intricate business rules, multiple lifecycle states, and evolving domain requirements.

Can I use both DDD and CRUD in the same Spring Boot application?

Yes. In fact, hybrid architectures are an enterprise best practice. Teams frequently use tactical DDD for complex Core Domains (e.g., checkout and billing) while using standard CRUD for simple Generic Subdomains (e.g., user profiles and country codes).

What is an Anemic Domain Model and why is it an anti-pattern?

An Anemic Domain Model is an entity design where classes contain only fields, getters, and setters with no business behavior. It is considered an anti-pattern because business logic is pushed into external services, breaking object-oriented encapsulation and allowing invalid states to be saved to the database.

Why are Java Records ideal for Value Objects in DDD?

Java Records are immutable by default, automatically provide structural equals() and hashCode() implementations, and support compact constructors for invariant validation. This makes them the perfect language construct for implementing DDD Value Objects.

Why should domain entities avoid JPA annotations?

Placing JPA annotations (like @Entity or @Table) directly on domain models tightly couples your business rules to database ORM mechanics. By separating pure domain models from infrastructure JPA entities, you can change your database schema without altering core business rules.

How does DDD improve database transaction performance compared to CRUD?

DDD restricts transaction boundaries to a single Aggregate Root, leveraging lightweight optimistic locking (@Version). In contrast, CRUD services often lock multiple tables across long transactions, increasing deadlock risks and database connection contention.

Comments