Why I Stopped Using DDD: Real‑World Pain Points and Lessons Learned

The article shares the author's practical frustrations with Domain‑Driven Design—covering confusing CQRS classifications, ambiguous aggregate‑root boundaries, performance conflicts, and the high time cost—while offering concrete examples, code snippets, and a candid conclusion that DDD should be applied judiciously rather than dogmatically.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Why I Stopped Using DDD: Real‑World Pain Points and Lessons Learned

Why I Don't Like DDD

This is not a DDD tutorial or best‑practice guide. It shares my real feelings and confusions after using DDD, hoping to resonate with anyone considering or already using DDD in a project.

Reason 1: CQRS classification gives me a headache

CQRS asks you to split every operation into three categories: Command, Query, Event. In theory it is clear, but in practice many operations don’t fit neatly into any category.

A real example

Suppose we have a service‑invocation system where a user calls a third‑party service via an API:

// User request
POST /api/service-invoke-requests
{
  "serviceId": "weather-api",
  "inputData": {
    "city": "Beijing"
  }
}

// Expected synchronous response
{
  "requestId": "req-123",
  "status": "SUCCESS",
  "outputData": {
    "temperature": "25°C",
    "weather": "Sunny"
  }
}

Now the question: should this interface be called ServiceInvokeCommand or ServiceInvokeQuery?

According to CQRS:

Is it a Command?

Command should change system state but return only minimal data (id, status).

This interface needs to return the result of the invoked service.

The interface reads user configuration and executes it; there is no obvious write operation.

Does not fit.

Is it a Query?

Query should be read‑only, idempotent, lightweight.

This interface calls an external service that may send SMS, charge fees, etc.

It may take several seconds to return.

Does not fit.

Is it an Event?

Event is a passive reaction to something that has already happened.

This is a user‑initiated operation.

Not an event.

Result: none of the three!

Many similar examples

Operation Type

Changes State?

Returns Data?

Synchronous?

Matches Which?

User registration

❌ (only id)

Command ✅

Query user list

Query ✅

Invoke third‑party service

???

Generate report

❌ (long)

???

Batch export data

???

AI‑generated content

???

My feeling

In reality:

Many operations both change state and need to return complex business data.

Different people interpret the definitions differently.

Code reviews often waste time debating “Command or Query?”.

The actual benefit of the debate is small.

Instead of arguing, I simply call everything a Request :

// Instead of fighting
CreateUserCommand
QueryUserByIdQuery

// Simpler
CreateUserRequest
GetUserByIdRequest

It may not be “pure”, but at least it stops the endless debate.

Reason 2: Aggregate‑root boundaries are hard to judge

DDD says you must define an aggregate root, but in real projects I often don’t know whether something should be an aggregate root.

A long‑standing example

Imagine a content community with a “favorite” feature:

favorite diagram
favorite diagram

How should this favorite be modeled?

From the user’s perspective:

Favorite belongs to “my favorite list”.

If the user is deleted, the favorite should be deleted.

Should favorite belong to the User aggregate?

From the article’s perspective:

Favorite contributes to the article’s favorite count.

If the article is deleted, the favorite should be deleted.

Should favorite belong to the Article aggregate?

From the behavior perspective:

Favorite has its own ID.

It can be queried independently.

Should it be an independent aggregate root?

From another angle it looks like a many‑to‑many join table with no complex business rules.

I tried several modeling options

Option A: Favorite as an independent aggregate root

public class Favorite {
    private FavoriteId id; // independent ID
    private UserId userId;
    private ArticleId articleId;
    private Instant createdAt;
    public void cancel() { /* cancel favorite */ }
}
public interface FavoriteRepository {
    Optional<Favorite> findById(FavoriteId id);
    void save(Favorite favorite);
}
// Problem: Favorite feels like a relation record rather than a domain entity

Option B: Favorite as part of User aggregate

public class User {
    private UserId id;
    private String username;
    private List<Favorite> favorites;
    public void favoriteArticle(ArticleId articleId) {
        if (hasFavorited(articleId)) {
            throw new AlreadyFavoritedException();
        }
        favorites.add(new Favorite(articleId));
    }
    public boolean hasFavorited(ArticleId articleId) {
        return favorites.stream()
            .anyMatch(f -> f.getArticleId().equals(articleId));
    }
}
// Problems:
// 1. A user may have thousands of favorites; loading all each time is heavy.
// 2. If the user is deleted, what happens to the article’s favorite count?

Option C: Favorite as part of Article aggregate

public class Article {
    private ArticleId id;
    private String title;
    private List<Favorite> favorites;
    public void receiveFavorite(UserId userId) {
        if (isFavoritedBy(userId)) {
            throw new AlreadyFavoritedException();
        }
        favorites.add(new Favorite(userId));
    }
}
// Problem: A popular article may have tens of thousands of favorites; loading all is impractical.

Option D: No domain model, just a database record

@Service
public class FavoriteService {
    @Autowired
    private JdbcTemplate jdbcTemplate;
    public void favoriteArticle(Long userId, Long articleId) {
        jdbcTemplate.update(
            "INSERT INTO favorite (user_id, article_id, created_at) VALUES (?, ?, NOW())",
            userId, articleId);
    }
    public boolean hasFavorited(Long userId, Long articleId) {
        return jdbcTemplate.queryForObject(
            "SELECT EXISTS(SELECT 1 FROM favorite WHERE user_id = ? AND article_id = ?)",
            Boolean.class, userId, articleId);
    }
}
// Problem: Completely abandons DDD modeling; business rules are scattered in the application layer.

All of these approaches have drawbacks, and the core dilemma is summarized in the following table.

Paradox of the judgment criteria

Let’s summarize the contradictions:

Judgment Dimension

Conclusion

Contradiction

Consistency boundary

Favorite does not need strong consistency with User/Article

Where is the boundary?

Transaction boundary

Favorite operation is independent, no need to share a transaction

Why do we need an aggregate?

Lifecycle

Favorite has an independent lifecycle

But it is just an association

Business rules

Simple scenario: almost no rules

No need for an aggregate root

Complex scenario

Many rules exist

Need an aggregate root

Quantity

A user or article may have thousands of actions

Cannot be placed inside an aggregate

Query pattern

We often need to check “whether liked”

Requires a separate table and index

My feeling

There is no standard answer. The same favorite feature can be modeled completely differently at different stages, and different people have different interpretations, leading to endless team debates with little payoff.

Reason 3: DDD conflicts with performance optimization

This is the most painful problem I have encountered.

A real example

When the project grew, the user table became huge and queries slowed down, so we split the table:

CREATE TABLE user (
    id BIGINT PRIMARY KEY,
    username VARCHAR(50),
    email VARCHAR(100),
    phone VARCHAR(20),
    created_at TIMESTAMP,
    -- detailed fields (low‑frequency, large)
    nickname VARCHAR(50),
    bio TEXT,
    avatar_url VARCHAR(200),
    address_json TEXT,
    preferences_json TEXT,
    social_links_json TEXT,
    -- many more fields
);

Problem: 10 million rows, 80 % of queries only need username and email, but scanning all columns is slow.

After splitting:

-- Basic info table (high‑frequency)
CREATE TABLE user (
    id BIGINT PRIMARY KEY,
    username VARCHAR(50),
    email VARCHAR(100),
    phone VARCHAR(20),
    created_at TIMESTAMP,
    INDEX idx_username (username),
    INDEX idx_email (email)
);
-- Detailed info table (low‑frequency)
CREATE TABLE user_detail (
    user_id BIGINT PRIMARY KEY,
    nickname VARCHAR(50),
    bio TEXT,
    avatar_url VARCHAR(200),
    address_json TEXT,
    preferences_json TEXT,
    social_links_json TEXT,
    FOREIGN KEY (user_id) REFERENCES user(id)
);

Performance improved, but DDD says User should be a complete aggregate root, while the data is now split across two tables. How to reconcile?

Attempt 1: Repository always returns the full aggregate

public class UserRepositoryImpl implements UserRepository {
    @Override
    public Optional<User> findById(UserId id) {
        // ✅ DDD‑compliant: returns full aggregate
        // ❌ Performance problem: always queries two tables
        UserPO userPO = userMapper.selectById(id.getValue());
        UserDetailPO detailPO = userDetailMapper.selectByUserId(id.getValue());
        return Optional.of(assembleUser(userPO, detailPO));
    }
}
@Service
public class UserApplicationService {
    public UserBasicInfoDTO getUserBasicInfo(UserId userId) {
        // Problem: only need username and email, but fetched both tables
        User user = userRepository.findById(userId).orElseThrow();
        return new UserBasicInfoDTO(user.getId(), user.getUsername(), user.getEmail());
    }
}

Result: 80 % of queries waste the extra table, nullifying the benefit of splitting.

Attempt 2: Lazy loading

public class User {
    private UserId id;
    private String username;
    private String email;
    private Supplier<UserProfile> profileLoader;
    private UserProfile profile;
    public UserProfile getProfile() {
        if (profile == null) {
            profile = profileLoader.get(); // ❌ triggers DB query on first access
        }
        return profile;
    }
}
public class UserRepositoryImpl implements UserRepository {
    @Override
    public Optional<User> findById(UserId id) {
        // Only query basic info
        UserPO userPO = userMapper.selectById(id.getValue());
        Supplier<UserProfile> profileLoader = () -> {
            UserDetailPO detailPO = userDetailMapper.selectByUserId(id.getValue());
            return toUserProfile(detailPO);
        };
        return Optional.of(User.reconstitute(id, username, email, profileLoader));
    }
}
@Service
public class UserApplicationService {
    public List<UserListItemDTO> listUsers(List<UserId> userIds) {
        List<User> users = userIds.stream()
            .map(id -> userRepository.findById(id).orElse(null))
            .collect(Collectors.toList());
        return users.stream()
            .map(user -> new UserListItemDTO(user.getId(), user.getProfile().getNickname())) // 💥 N+1 queries!
            .collect(Collectors.toList());
    }
}

Result: N+1 query problem and hidden DB calls.

Attempt 3: Repository provides different granularity queries

public interface UserRepository {
    Optional<User> findById(UserId id);               // full aggregate
    Optional<UserBasicInfo> findBasicInfoById(UserId id); // only basic info
    // Problem: UserBasicInfo is part of the aggregate; returning it breaks encapsulation.
}

Returning a partial object can cause bugs when business logic expects a complete entity.

Attempt 4: CQRS read‑write separation + query projection

// Write model: full aggregate
public class User { /* ... */ }
public interface UserRepository {
    Optional<User> findById(UserId id);
    void save(User user);
}
// Read model: projection for queries
public class UserListProjection {
    private Long id;
    private String username;
    private String avatarUrl;
}
public interface UserQueryRepository {
    List<UserListProjection> findUserList(UserQuery query);
}
@Service
public class UserApplicationService {
    public List<UserListItemDTO> listUsers() {
        List<UserListProjection> projections = userQueryRepository.findUserList(query);
        return toDTO(projections);
    }
    public void updateUserProfile(UserId userId, String bio) {
        User user = userRepository.findById(userId).orElseThrow();
        user.updateProfile(bio);
        userRepository.save(user);
    }
}

Problem: Maintaining two models adds complexity, and projection objects cannot contain business logic.

Attempt 5: Repository with multiple query methods

public interface UserRepository {
    Optional<User> findById(UserId id);               // full
    Optional<User> findByIdLite(UserId id);          // profile null or lazy
    Optional<User> findByIdWithProfile(UserId id);   // eager profile
    List<User> findByIds(List<UserId> ids);          // batch basic
    List<User> findByIdsWithProfile(List<UserId> ids);// batch with profile
}
// Problem: findByIdLite returns a “broken” User that may cause NPEs if profile is accessed.

All attempts are compromises between DDD purity and performance.

Reason 4: DDD consumes too much time

Agile development demands rapid iteration, but DDD requires extensive upfront modeling, which is inherently contradictory.

Scenario 1: Simple requirement

Product: “We need a favorite feature.”
Me: “Any business rules?”
Product: “Just a button.”
If I spend five days defining aggregates for such a trivial feature, the product may later say “We don’t need favorites anymore.”

Scenario 2: Changing requirements

Sprint 1: Users can favorite articles → 3 days modeling.
Sprint 2: Favorites need categories → 2 days refactor.
Sprint 3: Favorites can be shared → 3 days refactor.
Sprint 4: Remove categories → 2 days revert.
Total: 10 days vs. ~4 days with a simple CRUD approach.

Scenario 3: Team unfamiliar with DDD

Not everyone understands DDD; styles diverge, code reviews become DDD‑concept debates, and efficiency drops.

My feeling

Full DDD practice (aggregate roots, value objects, domain events, etc.) typically costs 2–3 × the time of a straightforward CRUD implementation. For simple business logic the investment is rarely worthwhile; for complex domains with frequent changes the cost of refactoring the model is high.

Final thoughts

After years of practice I conclude:

Most projects do not need a complete DDD implementation; 80 % can be handled with plain CRUD.

DDD theory has genuine issues: CQRS three‑classification is insufficient, it conflicts with performance optimization, and it is time‑consuming.

No one uses “pure” DDD in practice; teams compromise—commands return data, queries bypass repositories, aggregates are sometimes broken for performance, and many models end up as anemic objects.

DDD teaches valuable ideas such as focusing on business language and vertical domain decomposition, but it should not be applied dogmatically. Choose the architecture that fits the actual project.

Click “Read original” to see the details ~

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

software architectureDomain-Driven DesignDDDCQRS
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.