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.
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
GetUserByIdRequestIt 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:
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 entityOption 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 ~
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
