Skip to content
Back
Software design

Pragmatic DDD: When It Pays Off and When It Doesn't

When Domain-Driven Design earns its cost and when a transaction script is the better choice: a decision matrix, a path from simple to rich models, and the patterns teams overuse.

Michał Artur Marciniak

3 min read

On this page
  1. Prologue: The Cathedral of Complexity
  2. When DDD Saves You (And When It Doesn’t)
  3. Beyond the Dogma - When Simplicity Wins

How to Build Software That Bends Without Breaking


“Good architecture allows you to change your mind without changing the world.”


Prologue: The Cathedral of Complexity

It started with a simple user registration feature.

Six months later, the codebase had grown into something unrecognizable. Thirty-seven classes to create a user account. Six different DTOs for what was essentially name, email, and password. A domain event system that published “UserRegistered” events nobody consumed. Bounded contexts that mapped 1:1 to database tables.

The team had followed every DDD pattern in the book. They’d created aggregates for entities with no invariants. They’d defined repositories with specification patterns for simple CRUD. They’d built an event sourcing system for data that never changed.

The result? A codebase so complex that adding a new field required touching 12 files. Unit tests that took 3 minutes to start because they needed the Spring context. New developers who spent their first week just trying to understand where business logic lived.

This is the dark side of Domain-Driven Design. When followed dogmatically, it doesn’t simplify - it bloats.

Imagine you’re constructing a house. The Domain is the foundation - poured concrete, load-bearing walls, the very skeleton that keeps everything standing. The Application layer is the plumbing and electrical—connecting systems, directing flow, but never deciding where rooms should go. Infrastructure is the facade and fixtures—the paint, the windows, the doorknobs that can be swapped without touching the structure.

For decades, developers have been handed rigid architectural blueprints. DDD purists insist on every wall, every beam, every measurement following sacred patterns. But pragmatic DDD asks a different question: Does this room need a cathedral ceiling, or will standard height do?

The revolution is not in the complexity - it is in the selective application. In this series, we will explore how to build systems that are robust without being over-engineered, clear without being bureaucratic, and maintainable without requiring a PhD in design patterns.


When DDD Saves You (And When It Doesn’t)

The Decision Matrix

Before writing a single line of code, determine if DDD fits your context:

Use DDD when

  • Complex business rules, many paths through workflows

    Centralized business logic prevents scattering.

    For exampleE-commerce checkout with discounts, taxes and inventory

  • Long-term project (2+ years), evolving requirements

    Boundaries protect against future changes.

    For exampleEnterprise billing system with changing regulations

  • Multiple teams, changing developers

    Clear structure reduces onboarding friction.

    For exampleBanking platform with 5+ teams contributing

Keep it simple when

  • Simple CRUD, no complex rules

    Transaction scripts are faster to write and maintain.

    For exampleBlog post CMS: create, read, update and delete only

  • Rapid prototype or MVP

    YAGNI (You Are not Gonna Need It): validate first.

    For exampleStartup landing page with a contact form

  • Team unfamiliar with DDD

    Wrong abstractions are worse than no abstractions.

    For exampleJunior team with a tight deadline

The Evolution Strategy

Don’t start with DDD. Start simple, evolve as complexity demands:

  1. Weeks 1–4Transaction script
  2. Months 2–3Anemic domain model
  3. Months 4–6Rich domain model
  4. Year 1+Full DDDonly if needed

Transaction Script Pattern: Business logic lives in service methods, not domain objects. Each method represents a single transaction (e.g., registerUser(), processOrder()). Simple, fast to write, perfect for CRUD apps. Part 3 shows a before/after example.

Start at your current complexity level, not where you think you’ll be.

Case Study: Bounded Contexts Decision

Consider a small team building a user management system:

Scenario: 3 developers, 1 domain language, straightforward authentication

Over-engineered approach:

Separate contexts add ceremony without valueJava
@Context("UserManagement")
public class UserContext { ... }

@Context("Authentication")
public class AuthContext { ... }

Pragmatic approach:

One context with clear package separationJava
/domain/user/User.java
/domain/auth/Password.java

Decision factors:

  • Team size: Small (3 developers)
  • Domain complexity: Low (basic CRUD)
  • Language differences: None (unified terms)
  • Result: Package-level separation is sufficient

Beyond the Dogma - When Simplicity Wins

While DDD patterns are powerful, misapplication causes damage:

Proper useMisuse
AggregatesConsistency boundaries across multiple entitiesSingle entities with trivial constraints
Bounded contextsMultiple teams, different ubiquitous languagesSmall team, unified domain
Domain eventsCross-aggregate communication, eventual consistencySimple CRUD notifications
CQRSExtreme read/write ratio differencesStandard CRUD with no performance issues
Value objectsComplex validation, type safetyWrappers around primitives with no behaviour

Real-World Simplifications

We skipped CQRS because:

  • Our read/write ratio was 70/30 (not extreme)
  • Reports were simple SQL queries
  • Adding complexity wasn’t justified

We skipped Event Sourcing because:

  • Our audit requirements were met by database auditing
  • Replay scenarios were rare debugging tools
  • The complexity cost exceeded the benefit

We used enums instead of entities for:

  • Status codes (ACTIVE, INACTIVE)
  • Configuration flags
  • Simple reference data
Pragmatic: an enum for a simple statusJava
public enum OrderStatus {
    PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
Overkill: an entity for a status with no behaviourJava
@Entity
public class OrderStatusEntity {
    @Id private Long id;
    private String code;
    private String name;
}

When to Simplify Back

You covered “when not to use DDD” but knowing when to remove DDD is equally important:

Consider simplifying when:

  • Aggregates have only 1-2 methods with no complex rules
  • Use cases do nothing but call repository.save()
  • You spend more time maintaining layers than adding features
  • Team velocity drops after DDD adoption
  • Simple operations require touching 5+ files

Signs you’ve over-engineered:

This aggregate protects nothingJava
public class StatusAggregate {
    private StatusId id;
    private StatusName name;

    public void activate() { this.status = Status.ACTIVE; }
    public void deactivate() { this.status = Status.INACTIVE; }
    // No invariants, no rules - just state changes
}
Use an enum or primitiveJava
public enum Status { ACTIVE, INACTIVE }

Migration path back:

  1. Consolidate thin use cases into services
  2. Remove unnecessary value object wrappers
  3. Simplify aggregate boundaries
  4. Document why you simplified (for future you)

Remember: Architecture serves the code, not the other way around.