DDD Was Never the Problem. Your Rules Were.

DDD doesn't have to mean dozens of classes and layers of abstraction. Practical DDD aimed for business side of things will produce less code than CRUD.

DDD Was Never the Problem. Your Rules Were.

There are two versions of Domain Driven Design.

The first one — the one most developers encounter — is a maze of abstractions. Separate domain models and persistence models connected by fragile mappers. Application services that do nothing but load, call, save, publish. Interfaces for dependencies that haven't changed in a decade. It's exhausting. It's expensive. And it's the reason "DDD is over-engineering" became a meme.

The second version is the one I discovered after years of building the first. It has fewer files than CRUD. Every line expresses a business rule. No ceremony, no boilerplate, no layers that exist just to exist.

If you gave up on DDD because of the first version, I'd like to walk you through the second.

The Problem Everyone Sees

Search any developer forum for opinions on DDD and you'll find a chorus: "DDD is over-engineering." "It leads to a gazillion classes." "20-50x more code for no reason."

These aren't uninformed takes. They come from developers who tried DDD, followed the rules as taught, and got burned. The problem is real. but it's not DDD that failed them — it's a specific, dogmatic interpretation that front-loads abstraction, multiplies classes, and delivers complexity without proportional value.

Let me show you what they actually look like in code, so we can see what we're really dealing with.

1. Creating layers of boilerplate code

My first DDD project followed the rules to the letter. Each layer had to be separate. Each layer had its own data objects. Data was remapped between layers all the way to the Domain. We believed this provided isolation and decoupling.

It provided neither.

Here's what placing an order looked like. Start at the Controller — a Request DTO comes in:

class PlaceOrderRequest
{
    public function __construct(
        public readonly string $customerId,
        public readonly array $products,
    ) {}
}

The Controller maps it to an Application DTO and passes it to the Application Service:

class PlaceOrderApplicationAction
{
    public function __construct(
        public readonly string $customerId,
        public readonly array $products,
    ) {}
}

And then in the Application Handler, we remap everything again — this time into Domain objects — just to pass those arguments to the Aggregate:

class PlaceOrderHandler
{
    public function __construct(
        private OrderRepositoryInterface $repository,
        private EventBusInterface $eventBus,
    ) {}

    public function handle(PlaceOrderApplicationAction $action): void
    {
        $order = Order::place(
            OrderId::generate(),
            CustomerId::fromString($action->customerId),
            ProductList::fromArray($action->products)
        );
        $this->repository->save($order);
    }
}

On the upper layers we map our DTO Request to Application DTO, then in the Handler we map it again to Domain objects.

Following this approach have not made layers independent, we simply created illusion of decoupling. Things between layers are still having the same reason to change, as they are coupled to the same data - just with extra translation steps between them.

Having different representations of the same data does not decouple the layers. It just creates hidden dependency - which requires extra work when changing the structure.

Adding a single property — say, a discountCode — forced changes across every layer. The Request DTO, the Application DTO / the Command, the Handler, the Aggregate — all had to change in lockstep.

I've also seen projects that took this even further — keeping a "pure" Domain Aggregate separate from a Persistence Entity (e.g., a Doctrine ORM OrderEntity), connected with additional mapping code to achieve "decoupling". This creates even more representation of the same data, leading to application wide refactors when data changes.

Let's now jump to the second point - code abstraction.

2. Over-abstracting code

The next common pitfall in DDD projects is overloading the codebase with design patterns and abstractions. Patterns like Factory, Builder, Strategy are valuable tools — but when applied without questioning whether they earn their keep, they become a tax on every feature.

Take the Factory pattern. It's quite popular to create a Factory interface for things like identity generation. Even something as simple as AccountId gets its own Factory:

interface AccountIdFactoryInterface
{
    public function generate(): AccountId;
}

class UuidAccountIdFactory implements AccountIdFactoryInterface
{
    public function generate(): AccountId
    {
        return new AccountId(Uuid::uuid4()->toString());
    }
}

Two classes, an interface, Factory need to be wired in places where we need to generate it. The intent is to decouple us from the UUID library. But UUID has been the standard for quite some time now, and it's a pure computation — no I/O, no state. What exactly are we protecting ourselves from? A simple AccountId::generate() with the UUID encapsulated inside would do the same job.

In some projects I've even eliminated custom Identifier Objects completely, and used Uuid directly to keep the Domain explicit and avoid unnessecry classes

This over-abstraction doesn't stop at Factories and Builders — it creeps into the Domain itself. Strategy patterns introduced "just in case" we might switch an implementation later. Interfaces wrapping things that have one concrete implementation and will never have another. The Domain — the part of our system that should be the most concrete, the most explicit, the most readable — becomes a maze of indirection.

But here's the thing: if we ever do need to swap an implementation, we can introduce the abstraction at that point. It's a straightforward refactoring. What we can't easily undo is the cost of carrying unnecessary abstractions across every feature, every day, from day one.

The Domain should be the most concrete part of our application. It should just scream what it does — leaving no question marks, no layers of indirection, no ambiguity. Leave the abstractions for building frameworks and libraries. That's where they fit best.

And now we arrived at the final point - "Domain Purity".

3. Purity becomes the obsession

The next pitfall is when purity becomes the main goal of why we are building the Application. Every decision gets filtered through "is this pure enough?"

This starts on the top - Application layer, the representation of our abstractions being in use, where Application Service became the mandatory ceremony for every action:

class MarkOrderAsShippedHandler
{
    public function __construct(
        private OrderRepositoryInterface $repository,
        private EventBusInterface $eventBus,
    ) {}

    public function handle(MarkOrderAsShipped $command): void
    {
        $order = $this->repository->get(
            OrderId::fromString($command->orderId)
        );
        $order->markAsShipped();
        $this->repository->save($order);
        foreach ($order->getRecordedEvents() as $event) {
            $this->eventBus->publish($event);
        }
    }
}

In reallity, this handler does one thing: change a status. But the orchestration — load, call, save, publish — dwarfs it. And every single handler in the system looks nearly identical.

The problem is not that we want to be "pure." The problem is what we define as pure. This whole Handler is not really our business logic — it's orchestration logic, repetitive and automatable. Our Domain is that one line $order->markAsShipped() buried inside all of this.

The same purity mindset applies to Aggregates themselves. To keep the Domain "free from infrastructure," the ORM mapping gets pushed into external XML or YAML files:

<!-- Order.orm.xml -->
<entity name="App\Domain\Order" table="orders">
    <id name="orderId" type="string" column="order_id"/>
    <field name="customerId" type="string" column="customer_id"/>
    <field name="status" type="string" column="status" enumType="OrderStatus"/>
    <one-to-many field="lines" target-entity="OrderLine" mapped-by="order"/>
</entity>

The reasoning: "Attributes couple the Domain to infrastructure." - but what did we actually achieve? The mapping still exists — we just moved it to a separate file. The coupling is still there. When we add a field to the Aggregate, we still have to update the mapping. We haven't removed the dependency, we've hidden it — making it less visible, less explicit, and easier to forget.

Attributes are metadata - they don't change the business logic inside the Aggregate. cancel() works exactly the same whether there's an #[ORM\Column] above a property or an XML file somewhere in a config folder. If using attributes makes development more explicit and simpler for you — there is no reason to remove them in the name of purity.

Purity had become a dogma. It's often more important than what business logic we actually wanted to implement. Every feature became a boilerplate celebration — and the business rules, the thing DDD was supposed to elevate, becomes buried somewhere deep in the call stack.

When the ceremony exceeds the complexity of the problem, something has gone fundamentally wrong.

The Turning Point: Asking a Different Question

I've done the things described in this article, one way or another. Layers of boilerplate, over-abstraction, purity dogma — I've been through all of it. And after years of this, I think I understand why.

I do think that comes from asking the wrong question: "How can we make things more pure?"

That question leads exactly where you'd expect. More layers. More interfaces. More separation. More abstractions. More files. It leads to the feeling that so many developers have — that DDD is unnecessary complexity, pushing people to create huge amounts of classes and abstractions that serve no real purpose. And honestly? When purity is the goal, they're right. Yet it is unnecessary complexity.

But what if we asked a different question?

"How can we fully focus on business logic?"

This question leads somewhere completely different. It pushes you to eliminate everything that isn't business logic. The layers of boilerplate? Gone — they were orchestration, not business rules. The over-abstraction? Gone — it was indirection that obscured what the code actually does. The purity dogma? Gone — it was protecting us from changes that never came, at a cost we paid every day.

When you follow this question to its conclusion, you end up in a place where the only thing you write is business logic. Everything else is handled for you.

Colocate what belongs together

Start with persistence. If using Attributes in Aggregates speeds up the work and doesn't affect our business logic — there is no reason to skip it:

#[ORM\Entity]
#[ORM\Table(name: 'orders')]
class Order
{
    #[ORM\Id]
    #[ORM\Column(type: 'string')]
    private string $orderId;

    #[ORM\Column(type: 'string')]
    private string $customerId;

    #[ORM\Column(enumType: OrderStatus::class)]
    private OrderStatus $status;

    public function cancel(): void
    {
        if ($this->status === OrderStatus::SHIPPED) {
            throw new \DomainException("Cannot cancel shipped order");
        }

        $this->status = OrderStatus::CANCELLED;
    }
}

Doctrine ORM attributes on your Aggregate — they're metadata. They don't change what cancel() does. They just tell the ORM how to store the data, right next to the data itself. One file, one place to change. Add a field, and you add it once — the mapping and the business logic live together because they change together.

And if Active Record is your way of working — Eloquent, for example — the same reasoning applies. There is no real reason to create separate mapping layers, having a separate Aggregate object and a separate Database object. They are still coupled to the same schema. We just create more work by splitting them apart, and more places where things can go wrong.

If adding something along side our model doesn't affect our business logic, it's not impurity — it's pragmatism.

Aggregates are Message Handlers

The next piece of the puzzle came from Alan Kay. The man who coined "Object-Oriented Programming" later said he regretted the name. It put the focus on objects — their structure, their relationships, their hierarchies. But that was never the point.

"The big idea is messaging. [...] The key in making great and growable systems is how its modules communicate rather than what their internal properties and behaviors should be." — Alan Kay

In Kay's original vision, OOP was about objects communicating through messages. Not method calls on data structures. Messages. Inputs and outputs flowing through the system.

In Messaging, Message Handlers are the entrypoints — the things that receive messages and act on them. In DDD, Aggregates are the entrypoints — the guards that ensure business logic is fulfilled and protected. They are the same thing, viewed from a different perspective.

Commands — PlaceOrder, CancelOrder, ApplyDiscount — are messages. They describe business actions in business language. Events — OrderWasPlaced, OrderWasCancelled — are messages too. Commands are the input. Events are the output. Two sides of the same pipe.

When you connect them — when you make the Aggregate the Command Handler directly — something powerful happens. There is no layer in between anymore. Calling a Command is calling the Aggregate. It's 1:1. You cannot bypass the business logic, because there is no middleman to skip or shortcut through. The Application Service that used to sit between them — receiving the Command, loading the Aggregate, calling the method, saving, publishing — was just a mechanical relay. Remove it, and the system becomes both simpler and safer.

This insight — combining Messaging with DDD — drove the design of Ecotone Framework to completely erase the ceremony from DDD. Let me show you what changes when you follow this path.

The Surprise: Fewer Files Than CRUD

This is what surprises everyone, including DDD advocates. Let me show you what the Aggregate actually looks like when you treat it as the Command Handler:

#[Aggregate]
class Order
{
    #[Identifier]
    private string $orderId;

    private string $customerId;

    private OrderStatus $status;

    #[CommandHandler]
    public static function place(PlaceOrder $command): self
    {
        $order = new self(
            OrderId::generate(),
            $command->customerId,
            OrderStatus::PLACED,
        );
        
        $order->recordThat(new OrderWasPlaced($order->orderId));
        
        return $order;
    }
}

One file. Domain logic, persistence, command handling — all colocated. Events are returned from the method. The framework handles loading, saving, transactions, and event publishing.

No Application Service. No repository wiring. No manual event dispatching. The #[CommandHandler] attribute tells Ecotone to route Commands directly here. Static factory methods handle creation. Instance methods handle actions on existing Aggregates — Ecotone resolves the target instance from the Command's identifier automatically:

#[Aggregate]
class Order
{

    #[CommandHandler]
    public function cancel(CancelOrder $command): array
    {
        if ($this->status === OrderStatus::SHIPPED) {
            throw new \DomainException("Cannot cancel shipped order");
        }
    
        $this->status = OrderStatus::CANCELLED;
    
        return [new OrderWasCancelled($this->orderId)];
    }

An action method on the Aggregate. Ecotone loads the right instance based on the Command's identifier, calls the method, saves the Aggregate, and publishes the returned events — all automatically.

Now count the files for adding a new feature — say, "cancel order." One new Command class, one method added to the Aggregate, one action added to the Controller — 1 new file and 2 edits.

// The only new file
class CancelOrder
{
    public function __construct(
        #[TargetIdentifier]
        public readonly string $orderId,
    ) {}
}

Compare that to the 8-10 files we saw earlier. The "simpler" CRUD approach has more files. The "complex" DDD approach has fewer.

But we can take this further.

Step 1: Routing eliminates Command classes

For action commands that just change the state of Aggregate, do we really need a dedicated Command class? Since Commands are Messages, we can use routing. The Aggregate method gets a routing key, and the Controller sends the message directly:

// In the Aggregate
#[CommandHandler("order.cancel")]
public function cancel(): array
{
    if ($this->status === OrderStatus::SHIPPED) {
        throw new \DomainException("Cannot cancel shipped order");
    }

    $this->status = OrderStatus::CANCELLED;

    return [new OrderWasCancelled($this->orderId)];
}
// In the Controller
#[Route('/orders/{orderId}/cancel', methods: ['POST'])]
public function cancel(string $orderId, CommandBus $commandBus): Response
{
    $commandBus->send(
        command: [],
        routingKey: "order.cancel",
        metadata: ["aggregate.id" => $orderId]
    );

    return new Response(200);
}

No Command class at all. The aggregate.id metadata tells Ecotone which Aggregate instance to load. For a simple status change, the new feature is 0 new files — just 2 edits.

Step 2: Pass payload directly — skip transformation

When the Command does carry data, we still don't need to manually deserialize it. However with Ecotone we can pass the raw JSON payload and content type to the Command Bus, and Ecotone will deserialize it directly into the Command class:

#[Route('/orders', methods: ['POST'])]
public function place(Request $request, CommandBus $commandBus): Response
{
    $commandBus->sendWithRouting(
        routingKey: "order.place",
        command: $request->getContent(),
        commandMediaType: "application/json"
    );

    return new Response(201);
}

No Request DTOs. No manual mapping. No transformation layer. The JSON goes straight to the Command Handler, deserialized by the framework. The Controller becomes a thin pass-through — which is exactly what it should be.
We can push this even further.

Step 3: A single Controller for everything

If the Controller is just passing a routing key and a payload, we don't need a Controller per Aggregate. We can have one Command Controller and one Query Controller for the entire application:

#[Route('/api/{routingKey}', methods: ['POST'])]
public function command(
    string $routingKey,
    Request $request,
    CommandBus $commandBus
): Response {
    $result = $commandBus->sendWithRouting(
        routingKey: $routingKey,
        command: $request->getContent(),
        commandMediaType: "application/json",
        metadata: $request->query->all()
    );

    return new Response($result);
}

One Controller. Every new feature is just a method on the Aggregate. The routing key from the URL maps directly to the #[CommandHandler] routing key. The framework handles everything else.

Think about what this means. Adding a new business action to the system — a new rule, a new operation, a new capability — is one method on the Aggregate. That's it. No new files. No wiring. No boilerplate. Just business logic.
Therefore the new feature is 0 new files — just 1 edit.

What We Actually Needed

Look at where we started and where we ended up.

The first version of DDD — the one with 8-10 files per feature — put all the focus on the things outside of the Domain. Request DTOs, Application DTOs, Handlers, Repository interfaces, Repository implementations, Mappers, XML configs. From a high level, all of these non-business things distracted us from actually delivering features. We spent more time wiring infrastructure than writing business rules. The Domain became a small piece buried under layers of ceremony.

The second version eliminates all of that. The boilerplate — gone. The orchestration code — gone. The translation layers — gone. What's left is pure business logic. Where we can take it to the degree where business logic becomes the only code we need to write.

And here's what matters: we don't give anything up. Database transactions are still there — handled automatically. Event publishing is still there - available for us, Middlewares are still there — interceptors with #[Before], #[After], #[Around]. Access to DI Services is still there — inject them directly into handler method parameters when you need them.

This way of doing DDD makes building systems easier than common CRUD applications. Not in theory. In practice. Fewer files, fewer moving parts, fewer places for bugs to hide — and every line of code focused on what actually matters: our business logic.

If you've been burned by DDD before, I'd invite you to try Ecotone Framework. To see how to build systems that are fully focused on the business domain, so that you can get the feeling of building Enterprise grade applications in a way that is simpler than CRUD.