Loading content...
Domain-Driven Design is a powerful approach—but it is not a universal solution. Eric Evans himself has emphasized that DDD is specifically designed for complex domains where the investment in deep modeling pays off.
Applying full DDD to a simple CRUD application is like using a sledgehammer to hang a picture frame. The overhead is unjustified. The complexity is unnecessary. The project becomes harder, not easier.
Conversely, tackling a genuinely complex domain with a simple three-layer architecture is like trying to build a skyscraper with hand tools. You might make progress initially, but eventually the approach breaks down.
The art of software architecture lies in matching the approach to the problem. This page will equip you with the judgment to make that match correctly.
By the end of this page, you will be able to evaluate whether DDD is appropriate for a given project. You'll understand the characteristics that make domains suitable for DDD, recognize the costs and prerequisites, and know when simpler approaches are the better choice.
DDD is appropriate when certain conditions are met. Let's examine these criteria in detail:
Criterion 1: Domain Complexity
The primary criterion is the complexity of the domain itself. DDD shines when:
Criterion 2: Strategic Importance
DDD investment makes sense when the domain is strategically important to the business:
Criterion 3: Team Capability
DDD requires a certain level of team sophistication:
| Factor | DDD May Be Right | DDD May Be Overkill |
|---|---|---|
| Domain Complexity | Complex, nuanced business rules | Simple data entry/retrieval |
| Business Logic | Rich, interconnected policies | Mostly CRUD operations |
| Domain Experts | Available and engaged | Unavailable or uninterested |
| Project Lifespan | Years of evolution expected | Short-term or disposable |
| Team Size | Multiple developers/teams | Single developer |
| Business Investment | Core to business value | Utility or supporting tool |
| Change Frequency | Rules change as business evolves | Stable, unchanging requirements |
| Integration Scope | Multiple systems with different models | Standalone or simple integrations |
Let's explore specific scenarios where DDD is the appropriate choice:
Financial Services
Banking, insurance, trading, and investment systems are textbook DDD candidates. These domains feature:
Healthcare Systems
Clinical and administrative healthcare software often benefits from DDD:
Logistics and Supply Chain
Complex supply chain orchestration is well-suited to DDD:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
// EXAMPLE: Insurance Domain - Clearly Benefits from DDD// Note the complexity of rules and relationships class InsurancePolicy { constructor( private readonly policyNumber: PolicyNumber, private readonly policyholder: Policyholder, private coverages: Coverage[], private status: PolicyStatus, private readonly underwritingDecision: UnderwritingDecision, private readonly paymentSchedule: PaymentSchedule ) {} // Complex business rule: Can this policy be reinstated? canReinstate(): ReinstatementEligibility { // Multiple conditions that domain experts specified if (!this.status.isLapsed()) { return ReinstatementEligibility.notApplicable('Policy is not lapsed'); } if (this.daysSinceLapse() > this.gracePeriod().days) { return ReinstatementEligibility.requiresNewUnderwriting( 'Lapse exceeds grace period' ); } if (this.hasUnpaidClaims()) { return ReinstatementEligibility.notEligible( 'Outstanding claims must be resolved' ); } if (this.policyholder.hasAdverseRiskIndicators()) { return ReinstatementEligibility.requiresReview( 'Risk profile changed during lapse' ); } const reinstatementPremium = this.calculateReinstatementPremium(); return ReinstatementEligibility.eligible(reinstatementPremium); } // Value calculation with many factors private calculateReinstatementPremium(): Money { const basePremium = this.currentPremium(); const lapseFee = this.lapseFeeSchedule().feeFor(this.daysSinceLapse()); const interestOnMissedPayments = this.paymentSchedule .missedPayments() .map(payment => payment.withInterest(this.latePaymentInterestRate())) .reduce((sum, payment) => sum.add(payment), Money.zero()); return basePremium.add(lapseFee).add(interestOnMissedPayments); } // Claim filing involves multiple checks and domain events fileClaim(claim: ClaimRequest): ClaimResult { const coverageCheck = this.findApplicableCoverage(claim.claimType); if (!coverageCheck.found) { return ClaimResult.notCovered(claim.claimType); } const coverage = coverageCheck.coverage; if (!coverage.isActiveOn(claim.incidentDate)) { return ClaimResult.outsideCoveragePeriod(claim.incidentDate); } if (claim.amount.exceeds(coverage.remainingLimit())) { return ClaimResult.exceedsLimit(coverage.remainingLimit()); } const newClaim = Claim.create( this.policyNumber, claim, coverage.id ); this.raise(new ClaimFiled(this.policyNumber, newClaim.id)); return ClaimResult.success(newClaim); }} // This domain clearly justifies DDD:// - Complex, interconnected business rules// - Multiple domain concepts with specific behaviors// - Rich vocabulary (reinstatement, lapse, coverage, underwriting)// - Significant business value from correctness// - Long lifecycle with evolving rulesDDD's power comes at a cost. When those costs outweigh the benefits, simpler approaches are better. Here are scenarios where DDD is likely overkill:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// SIMPLE CRUD - DDD is overkill here// Active Record or Transaction Script is fine // This is a contact management system// - Store contact information// - Search and filter// - Basic validation interface Contact { id: string; firstName: string; lastName: string; email: string; phone?: string; company?: string; createdAt: Date; updatedAt: Date;} class ContactService { async create(data: CreateContactDTO): Promise<Contact> { this.validateEmail(data.email); return await this.repository.save({ ...data, id: generateId(), createdAt: new Date(), updatedAt: new Date(), }); } async update(id: string, data: UpdateContactDTO): Promise<Contact> { const contact = await this.repository.findById(id); if (!contact) throw new NotFoundError('Contact'); if (data.email) { this.validateEmail(data.email); } return await this.repository.save({ ...contact, ...data, updatedAt: new Date(), }); } async search(query: ContactQuery): Promise<Contact[]> { return await this.repository.search(query); }} // No complex business rules// No rich domain model needed// Simple layer is appropriateWhy DDD Doesn't Fit Here:
No domain complexity — The 'domain' is just storing and retrieving data. There are no intricate business rules.
Minimal behavior — Contacts don't 'do' anything. They're data records without complex lifecycle or interactions.
No Ubiquitous Language needed — Everyone knows what 'contact', 'email', 'phone' mean. No specialized vocabulary.
CRUD is the domain — The operations ARE the simple create, read, update, delete. That's not hiding complexity—that IS the requirement.
Better Approach:
Use a simple three-layer architecture (Controller → Service → Repository) or even just a framework's built-in patterns. The cognitive overhead of DDD patterns would slow development without adding value.
The worst outcome is applying DDD patterns without understanding them—creating 'Entities' that are just data classes, 'Repositories' that are just DAOs with different names, 'Domain Services' that contain all the logic that should be in entities. This adds complexity without benefits. If you're not going to do DDD properly, don't pretend to do it at all.
DDD is not free. Understanding the costs helps you make an informed decision:
Upfront Investment
DDD requires significant upfront time:
Ongoing Costs
Cognitive Overhead
DDD introduces abstractions:
These costs are WORTH IT when:
| Domain Complexity | Project Duration | Recommendation |
|---|---|---|
| Low (CRUD-like) | Short (< 6 months) | ❌ Avoid DDD - use simple patterns |
| Low (CRUD-like) | Long (years) | ⚠️ Probably not DDD - but consider for core parts |
| Medium (some rules) | Short (< 6 months) | ⚠️ Partial DDD - focus on core complex areas only |
| Medium (some rules) | Long (years) | ✅ DDD for core domain, simple patterns elsewhere |
| High (complex rules) | Short (< 6 months) | ⚠️ DDD if rules are critical - accept slower start |
| High (complex rules) | Long (years) | ✅ Full DDD - investment will pay off many times over |
DDD doesn't have to be all-or-nothing. Many teams successfully adopt DDD incrementally:
Strategy 1: Core Domain First
Identify the most complex, strategically important part of your system. Apply full DDD there. Use simpler patterns elsewhere. As the team gains experience, expand DDD to other areas if warranted.
Strategy 2: New Context Approach
When adding a new bounded context (a new system area), build it with DDD from the start. Legacy areas don't need to be converted. Over time, more of the system becomes DDD-based.
Strategy 3: Strangler Fig Pattern
Gradually replace legacy code with DDD-modeled code. Route requests through new code. Decommission legacy piece by piece. The new DDD model 'strangles' the old code.
Strategy 4: Event-First Introduction
Start by introducing Domain Events without full DDD. Events force you to identify significant domain occurrences. This naturally leads to better modeling. Add other tactical patterns as understanding grows.
The most successful DDD adoptions start with a small, well-understood area where the team can learn the patterns without risking the whole project. Once the team has internalized DDD thinking, they can apply it more broadly with confidence.
Before committing to DDD, facilitate honest conversations about these questions:
It's better to acknowledge you're not ready for DDD than to attempt it and fail. A well-executed simple architecture beats a poorly executed DDD implementation. If the prerequisites aren't met, work on meeting them first or choose a simpler approach.
If DDD isn't appropriate, what should you use instead? Several simpler patterns serve well for less complex domains:
| Pattern | Best For | Key Characteristic |
|---|---|---|
| Transaction Script | Simple, procedural business logic | Organize logic by use case, procedures manipulate data |
| Active Record | Database-centric apps with simple logic | Domain objects know how to persist themselves |
| Table Module | Multiple records, shared logic per table | One class handles business logic for entire table/entity type |
| Service Layer + DTOs | API-focused apps with thin domain | Services coordinate, DTOs transfer, logic in services |
| CQRS without DDD | Read-heavy apps with simple writes | Separate read/write models but without rich domain |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// TRANSACTION SCRIPT PATTERN// Perfectly appropriate for simple domains class BlogPostService { constructor( private readonly db: Database, private readonly cache: Cache, private readonly search: SearchIndex ) {} // Each use case is a script/procedure async createPost(authorId: string, title: string, content: string): Promise<Post> { // Validate if (!title || title.length > 200) { throw new ValidationError('Title must be 1-200 characters'); } // Create record const post = await this.db.posts.create({ authorId, title, content, slug: slugify(title), publishedAt: null, createdAt: new Date() }); // Side effects await this.search.indexPost(post); return post; } async publishPost(postId: string): Promise<Post> { const post = await this.db.posts.findById(postId); if (!post) throw new NotFoundError('Post'); if (post.publishedAt) { throw new ConflictError('Post already published'); } const updated = await this.db.posts.update(postId, { publishedAt: new Date() }); await this.cache.invalidate(`post:${postId}`); await this.search.reindexPost(updated); return updated; }} // This is NOT bad design for a blog platform!// The domain is simple enough that this works well.// No fake "Entities" or "Value Objects" needed.The key insight: Match the pattern to the problem complexity. Transaction Script is excellent for CRUD-heavy, procedural logic. Active Record works well for database-centric applications. You don't need DDD for everything—but you DO need DDD for truly complex domains.
The decision to use DDD is strategic. It should be based on honest assessment of domain complexity, organizational readiness, and project characteristics—not on fashion or resume-building.
Module Complete:
You've now completed the introduction to Domain-Driven Design. You understand what DDD is, how Strategic and Tactical patterns work together, the essential role of Ubiquitous Language, and when DDD is the right choice. This foundation prepares you for deep dives into specific DDD patterns in the modules that follow.
You now have the foundation to make informed decisions about DDD adoption. In subsequent modules, we'll explore the specific patterns in depth—Entities and Value Objects, Aggregates and Repositories, Domain Events, and Domain Services. Each builds on the understanding you've developed here.