DEV Community

Cover image for DDD Design Approach(PHP): Why Your Code Turns Into Spaghetti (And How to Fix It)
Igor Nosatov
Igor Nosatov

Posted on

DDD Design Approach(PHP): Why Your Code Turns Into Spaghetti (And How to Fix It)

TL;DR

💡 Clean Architecture isn't about folders — it's a mindset

⚠️ 73% of projects die from technical debt

✅ 4 layers will save your project from a $100k refactor

🎁 Ready-to-use folder structure at the end

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

😱 The Problem: Why Everything Gets Worse

I remember my first "real" project. After 6 months, I was scared to touch my own code. Adding a field to a form? 2 hours. Changing discount logic? A prayer and 3 hours.

Sound familiar? Here are typical "spaghetti code" symptoms:

⚠️ Common Mistakes:

  • 500-line controllers (hello, God Object!)
  • Business logic inside SQL queries
  • "Quick fixes" directly in templates
  • Impossible to test without a database

💬 "Good architecture costs more upfront. Bad architecture costs 10x more later."
— Every senior dev's experience

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🎯 The Solution: Clean Architecture Explained

What is it anyway?

Clean Architecture by Robert Martin ("Uncle Bob") isn't about Symfony or Laravel. It's about the Dependency Rule:

🔺 Outer layers depend on inner layers. Never the other way around.

┌─────────────────────────────────┐
│   Presentation (UI, API)        │  ← knows about Application
├─────────────────────────────────┤
│   Application (Use Cases)       │  ← knows about Domain
├─────────────────────────────────┤
│   Domain (Business Logic)       │  ← knows NOTHING!
├─────────────────────────────────┤
│   Infrastructure (DB, HTTP)     │  ← implements Domain interfaces
└─────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

🏗️ The 4 Layers Simplified

1️⃣ Domain Layer — Business Core

This is your application's heart. Entities that your business can't exist without.

// Domain/Entity/Order.php
final class Order 
{
    private OrderId $id;
    private Money $total;
    private OrderStatus $status;

    public static function create(Email $customerEmail, Money $total): self 
    {
        $order = new self(OrderId::generate(), $total);
        $order->recordEvent(new OrderCreated($order->id));
        return $order;
    }

    // ❌ NO mentions of Doctrine, HTTP, JSON!
}
Enter fullscreen mode Exit fullscreen mode

💡 Key Idea: Domain doesn't know about databases, APIs, or frameworks.

2️⃣ Application Layer — The Orchestrator

Here live Use Cases (user scenarios). Receive request → call domain → return result.

// Application/UseCase/CreateOrder/CreateOrderHandler.php
final class CreateOrderHandler 
{
    public function __construct(
        private OrderRepositoryInterface $repo,
        private EventBus $eventBus
    ) {}

    public function __invoke(CreateOrderCommand $cmd): CreateOrderResponse 
    {
        // 1. Validate input
        $email = Email::fromString($cmd->customerEmail);
        $total = Money::from($cmd->total, 'USD');

        // 2. Execute business logic
        $order = Order::create($email, $total);

        // 3. Persist
        $this->repo->save($order);
        $this->eventBus->dispatch(new OrderCreated($order->getId()));

        return new CreateOrderResponse($order->getId());
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice: Handler coordinates but doesn't contain business rules.

3️⃣ Infrastructure Layer — The Dirty Work

Doctrine, Redis, HTTP clients, email services. All "technical stuff".

// Infrastructure/Repository/DoctrineOrderRepository.php
final class DoctrineOrderRepository implements OrderRepositoryInterface 
{
    public function save(Order $order): void 
    {
        // Map Domain Entity → Doctrine Entity
        $entity = $this->mapper->toDoctrineEntity($order);
        $this->entityManager->persist($entity);
        $this->entityManager->flush();
    }
}
Enter fullscreen mode Exit fullscreen mode

4️⃣ Presentation Layer — Entry Point

Controllers, CLI commands, GraphQL resolvers.

// Infrastructure/Presentation/Controller/CreateOrderController.php
final class CreateOrderController 
{
    public function __invoke(
        Request $request, 
        CreateOrderHandler $handler
    ): JsonResponse {
        $cmd = new CreateOrderCommand(
            customerEmail: $request->get('email'),
            items: $request->get('items')
        );

        $response = $handler($cmd);

        return $this->json(['orderId' => $response->orderId], 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🔥 Real Example: Before vs After

❌ Before (Classic MVC Nightmare)

// Controller doing EVERYTHING
class OrderController 
{
    public function create(Request $request) 
    {
        // Validation in controller 😱
        if (!filter_var($request->get('email'), FILTER_VALIDATE_EMAIL)) {
            return $this->json(['error' => 'Invalid email'], 400);
        }

        // Business logic in controller 😱
        $total = 0;
        foreach ($request->get('items') as $item) {
            $total += $item['price'] * $item['qty'];
        }

        // Direct DB access 😱
        $order = new Order();
        $order->setEmail($request->get('email'));
        $order->setTotal($total);
        $this->em->persist($order);
        $this->em->flush();

        // Email sending in controller 😱
        $this->mailer->send('Order created!');

        return $this->json($order);
    }
}
Enter fullscreen mode Exit fullscreen mode

Problems:
⚠️ Can't test without HTTP request

⚠️ Can't reuse logic in CLI commands

⚠️ Business rules scattered everywhere

⚠️ Changing email library breaks the controller

✅ After (Clean Architecture)

// Controller: just input/output
final class CreateOrderController 
{
    public function __invoke(Request $req, CreateOrderHandler $handler): JsonResponse 
    {
        return $this->json(
            $handler(new CreateOrderCommand($req->get('email'), $req->get('items')))
        );
    }
}

// Handler: orchestration
final class CreateOrderHandler 
{
    public function __invoke(CreateOrderCommand $cmd): OrderResponse 
    {
        $order = Order::create(
            Email::fromString($cmd->email),
            $this->calculator->calculateTotal($cmd->items)
        );

        $this->repo->save($order);
        $this->eventBus->dispatch(new OrderCreated($order->getId()));

        return OrderResponse::fromDomain($order);
    }
}

// Domain: pure business logic
final class Order 
{
    public static function create(Email $email, Money $total): self 
    {
        if ($total->isNegative()) {
            throw new InvalidOrderException('Total cannot be negative');
        }
        return new self(OrderId::generate(), $email, $total);
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefits:
✅ Test Handler without HTTP

✅ Reuse in CLI: php bin/console order:create

✅ Business rules in one place (Domain)

✅ Swap Doctrine for MongoDB? Change only Infrastructure

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

📊 When NOT to Use Clean Architecture?

Let me be honest: Clean Architecture is always worth it. But not every client pays for quality.

🎯 Skip it if:

  • Prototype with 2-week lifespan
  • Client refuses to pay for proper architecture
  • Solo hackathon project

🔥 Use it if:

  • Project will live 1+ years
  • Team has 2+ developers
  • Business logic is complex
  • You care about your sanity

💬 "Clean Architecture is like brushing teeth. Skipping once won't kill you. Skipping always will."

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🎁 Ready-to-Use Folder Structure

src/BoundedContext/OrderProcessing/
├── Domain/
│   ├── Entity/
│   │   └── Order.php
│   ├── ValueObject/
│   │   ├── Money.php
│   │   ├── Email.php
│   │   └── OrderId.php
│   ├── Repository/
│   │   └── OrderRepositoryInterface.php
│   └── Event/
│       └── OrderCreated.php
│
├── Application/
│   └── UseCase/
│       ├── CreateOrder/
│       │   ├── CreateOrderCommand.php
│       │   ├── CreateOrderHandler.php
│       │   └── CreateOrderResponse.php
│       └── GetOrder/
│           ├── GetOrderQuery.php
│           └── GetOrderHandler.php
│
└── Infrastructure/
    ├── Repository/
    │   └── DoctrineOrderRepository.php
    ├── Mapper/
    │   └── OrderMapper.php
    └── Presentation/
        └── Controller/
            └── CreateOrderController.php
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🚀 Action Plan: Start Today

Week 1: Create Domain folder, move entities

Week 2: Extract business logic from controllers

Week 3: Create Use Case handlers

Week 4: Introduce Repository interfaces

Don't refactor everything at once! New features — new architecture. Old code can wait.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

💬 Your Turn

What's stopping you from implementing Clean Architecture? Is it time, team pushback, or "legacy code too big"?

Share your biggest architecture pain point in the comments! 👇

Found this useful? Follow for more software architecture deep dives.

CleanArchitecture #SoftwareDesign #DDD #PHP #Symfony #SoftwareEngineering

Top comments (0)