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)