Make your Domain speak the business language
Why organizing your DDD domain layer into Aggregates/, ValueObjects/, and Repositories/ folders undermines the very goal of Domain-Driven Design.
You join a new project. The team tells you it's built with Domain-Driven Design. You open the Domain/ folder expecting to learn what the system does — and instead you find: Aggregates/, ValueObjects/, Repositories/, Services/, Events/, Exceptions/. You've learned nothing about the business. You've learned a lot about the team's DDD vocabulary.
This is one of the most common structural patterns in DDD codebases, and in my experience, it actively works against the thing DDD is supposed to accomplish.
The Pattern That Feels Right But Isn't
Domain-Driven Design exists for one reason: to align software with business reality so developers and domain experts speak the same language. The folder structure of your domain layer is the first thing a new developer sees. It shapes how they think about the system before they read a single line of code.
So what happens when that structure looks like this?
Domain/
├── Aggregates/
│ ├── Order.php
│ ├── Wallet.php
│ └── Account.php
├── ValueObjects/
│ ├── Money.php
│ ├── Currency.php
│ ├── OrderId.php
│ └── WalletId.php
├── Repositories/
│ ├── OrderRepository.php
│ └── WalletRepository.php
├── Services/
│ ├── BalanceCalculator.php
│ └── PricingService.php
├── Events/
│ ├── OrderPlaced.php
│ └── WalletCredited.php
└── Exceptions/
├── InsufficientFundsException.php
└── OrderAlreadyShippedException.php
A typical DDD codebase organized by technical building block — it tells you about DDD patterns, not about what the system does.
It looks tidy. Symmetrical. Organized. It satisfies that developer craving for clean classification — forks with forks, knives with knives.
But a domain model isn't a utility drawer. It's a map of business capabilities. And this structure scatters every business concept across six directories. Want to understand how wallets work? Pull from Aggregates/, ValueObjects/, Repositories/, Services/, Events/, and Exceptions/. The domain layer — which should be the most readable part of the system — becomes a scavenger hunt.
A business expert looking at this folder tree learns absolutely nothing about what the system does. They learn it has aggregates and value objects. That's DDD jargon, not business language. The ubiquitous language is missing from the one place it should be most visible.
Organizing by Business Capability
The solution, which I instead is to organize the domain layer around the Aggregate root and the concepts that gravitate around it. Each sub-module represents a business capability.
Domain/
├── Wallet/
│ ├── Wallet.php
│ ├── WalletId.php
│ ├── Money.php
│ ├── WalletRepository.php
│ ├── WalletCredited.php
│ ├── WalletDebited.php
│ ├── BalancePolicy.php
│ └── InsufficientFunds.php
├── Order/
│ ├── Order.php
│ ├── OrderId.php
│ ├── OrderLine.php
│ ├── OrderRepository.php
│ ├── OrderPlaced.php
│ └── PricingService.php
├── Promotion/
│ ├── Promotion.php
│ ├── DiscountRule.php
│ ├── PromotionRepository.php
│ └── PromotionApplied.php
└── Account/
├── Account.php
├── Email.php
├── AccountRepository.php
└── AccountActivated.php
The same codebase organized by business capability — the folder tree now reads like a description of what the system does.
A new developer opens this and immediately sees: this system deals with Wallets, Orders, Promotions, and Accounts. No DDD knowledge required. A business expert could look at this structure and nod — those are the things the business cares about.
The Aggregate is the natural center of gravity here, and this isn't an arbitrary choice. In DDD, the Aggregate already defines the consistency boundary — what changes together goes together.
Everything inside a business sub-module exists to support or interact with that Aggregate. Value Objects are its building blocks. Events are what it emits. The Repository is how it's persisted. Exceptions represent its invariant violations. The Aggregate already is the organizing principle — the directory structure now simply reflects that reality.
We can of course make a bit of hybrid approach when the volume of classes is getting too big, but yet still within the module itself. E.g. introduce Domain/Wallet/Event and Domain/Wallet/Command, and the main Model on the top level.
Let me show the difference concretely. Here's how a Wallet looks in the business-capability structure, using Ecotone Framework which supports this approach natively through PHP attributes:
// Domain/Wallet/Wallet.php
namespace Domain\Wallet;
use Ecotone\Modelling\Attribute\Aggregate;
use Ecotone\Modelling\Attribute\Identifier;
use Ecotone\Modelling\Attribute\CommandHandler;
#[Aggregate]
class Wallet
{
#[Identifier]
private WalletId $id;
private Money $balance;
#[CommandHandler]
public static function create(CreateWallet $command): self
{
$wallet = new self();
$wallet->id = new WalletId($command->walletId);
$wallet->balance = Money::zero($command->currency);
return $wallet;
}
#[CommandHandler]
public function credit(CreditWallet $command): void
{
$this->balance = $this->balance->add($command->amount);
}
#[CommandHandler]
public function debit(DebitWallet $command): void
{
if ($this->balance->isLessThan($command->amount)) {
throw InsufficientFunds::forWallet($this->id, $command->amount, $this->balance);
}
$this->balance = $this->balance->subtract($command->amount);
}
}
The Wallet aggregate — every class it references lives in the same Domain/Wallet/ directory. #[Aggregate] tells the framework this is an Aggregate; #[CommandHandler] marks business operations. No base class to extend, no repository interface to write — the framework provides that automatically.
Money, WalletId, CreditWallet, InsufficientFunds — all in the same namespace, same directory. No scavenger hunt. The namespace is the business context.
And here's the thing about knowing a class's DDD role — you don't need a folder for that:
// Domain/Wallet/Money.php
namespace Domain\Wallet;
final readonly class Money
{
public function __construct(
public int $amount,
public Currency $currency,
) {}
public function add(self $other): self
{
assert($this->currency === $other->currency);
return new self($this->amount + $other->amount, $this->currency);
}
}
Money is obviously a Value Object — immutable, no identity, value-based equality. The code tells you that. The directory's job is to tell you it belongs to the Wallet capability.
You didn't need a ValueObjects/ directory to know Money is a Value Object. The class makes it self-evident. What the directory does tell you is that Money belongs to the Wallet business capability — something the class alone cannot communicate.
How the Framework Reinforces Business Alignment
Ecotone Framework philosophy naturally eliminates the gravitational pull toward technical grouping.
Traditional DDD frameworks push toward technical structure almost accidentally. You extend AggregateRoot, implement RepositoryInterface, register services by namespace convention. Each of these mechanics subtly whispers: "group me with my kind." The WalletRepository interface wants to sit next to OrderRepository. The base class creates a family resemblance between Aggregates that makes them feel like they belong together. Ecotone sidesteps all of this.
Ecotone discovers everything through PHP attributes —#[Aggregate],#[CommandHandler],#[EventHandler],#[QueryHandler]. It genuinely does not care where your files live. No base class to extend, no interface to implement, no namespace-based registration. The#[Aggregate]attribute on a plain PHP class is all it needs.
This means:
- No framework inheritance in your domain. Your Aggregate is a plain PHP class, no extending or implementing framework specific classes that emphasize structural types (e.g. BaseAggregate).
- Attribute-based discovery. The framework scans for
#[Aggregate],#[CommandHandler],#[EventHandler],#[QueryHandler]. It does not care about directory structure. You're free to organize entirely by business capability. - Command handling lives on the Aggregate.
#[CommandHandler]on thecredit()method means the Aggregate is the handler. Meaning there is no even a need for Application layer split, everything gravitates towards your Aggregate.
For event-sourced domains, the same philosophy holds:
// Domain/Wallet/Wallet.php
namespace Domain\Wallet;
use Ecotone\Modelling\Attribute\EventSourcingAggregate;
use Ecotone\Modelling\Attribute\EventSourcingHandler;
use Ecotone\Modelling\Attribute\Identifier;
use Ecotone\Modelling\Attribute\CommandHandler;
#[EventSourcingAggregate]
class Wallet
{
#[Identifier]
private string $walletId;
private Money $balance;
#[CommandHandler]
public static function create(CreateWallet $command): array
{
return [new WalletCreated($command->walletId, $command->currency)];
}
#[CommandHandler]
public function credit(CreditWallet $command): array
{
return [new WalletCredited($this->walletId, $command->amount)];
}
#[CommandHandler]
public function debit(DebitWallet $command): array
{
if ($this->balance->isLessThan($command->amount)) {
throw InsufficientFunds::forWallet($this->walletId, $command->amount, $this->balance);
}
return [new WalletDebited($this->walletId, $command->amount)];
}
#[EventSourcingHandler]
public function applyCreated(WalletCreated $event): void
{
$this->walletId = $event->walletId;
$this->balance = Money::zero($event->currency);
}
#[EventSourcingHandler]
public function applyCredited(WalletCredited $event): void
{
$this->balance = $this->balance->add($event->amount);
}
#[EventSourcingHandler]
public function applyDebited(WalletDebited $event): void
{
$this->balance = $this->balance->subtract($event->amount);
}
}
Switch from #[Aggregate] to #[EventSourcingAggregate], return events from command handlers, add #[EventSourcingHandler] methods — and you have a fully event-sourced Aggregate. Still a plain PHP class. Still lives in Domain/Wallet/.
The insight that clicked for me: attributes encode the DDD role, directories encode the business capability. Each communicates what the other cannot. The #[Aggregate] attribute tells you what it is; the Domain/Wallet/ directory tells you what it's for. When your framework and your structure each carry the right information, the whole system becomes more legible.
Handling the Edge Cases
Two practical questions always come up with this approach.
What about shared concepts? Some Value Objects like Currency or DateRange genuinely cross module boundaries. These go in a Shared/ module. The discipline is: only put something there when it's used by three or more modules. Two usages? Pick the primary owner or duplicate. A small Shared/ module is healthy. A large one means your boundaries need rethinking.
Domain/
├── Shared/
│ └── Currency.php
├── Wallet/
├── Order/
└── Promotion/
Shared concepts get their own small module — but keep it minimal.
What about cross-aggregate services? A TransferService that moves money between wallets — where does it live? Usually in Wallet/. It operates on Wallets, uses Wallet language, enforces Wallet invariants. If it truly spans multiple Aggregates with equal weight, it might warrant its own module (Domain/Transfer/). And that's actually a discovery moment — you just found a business concept that was hidden when everything sat in a generic Services/ folder.
This is one of the surprises that made this approach click for me. When you force yourself to organize by business capability, you're forced to name things in business terms. That DiscountCalculationService sitting in Services/ — where does it go? Probably Promotion/. But it depends on Order data too. Is there a Pricing/ concept hiding here? The structure becomes a tool for domain exploration, not just code organization.
The Trade-Offs Worth Acknowledging
In my experience, the most common objection is: "But I want to see all my repositories at a glance!" The question to ask back is: when do you actually need that? If you're working on wallet features, you need WalletRepository. You find it in Wallet/. If you're doing a cross-cutting infrastructure concern like switching the ORM, you grep for interface.*Repository — the directory structure is irrelevant for that task anyway. The desire to "see all repositories" is a technical impulse, not a business need.
Another trade-off: you'll sometimes duplicate similar Value Objects across modules. Money in Wallet and Price in Order might look alike. But they represent different business concepts with potentially different invariants. This duplication is correctness, not a smell. The DRY principle applies within a boundary, not across boundaries - and it's actually about not duplicating knowledge, not the code.
Early in a project, you'll also get boundaries wrong. That's fine. The cost of moving a class between business sub-modules is low — change the namespace, update imports. The cost of having the wrong organizational axis (technical vs. business) is a codebase that trains every developer to think in the wrong terms.
Why This Matters Beyond Folder Structure
- Organize by reason for change, not by technical classification. When a business requirement changes, you want all affected code in one place. This principle extends far beyond DDD.
- Your directory structure is a communication tool. It shapes mental models before anyone reads a line of code. Choose what it communicates carefully.
- Don't encode metadata as structure. A class being a Value Object is metadata you can discover by reading it. Directory hierarchy should encode relationships and boundaries — things you can't discover from a single class.
- The Aggregate is a natural module boundary. In any architecture — microservices, modular monoliths, packages — the consistency boundary naturally defines what belongs together.
- Resist premature classification. Creating
ValueObjects/andServices/folders on day one forces you to categorize before you understand the domain. Start with business modules and let the patterns emerge.
The Shift That Matters
The biggest misconception I see is that the technical split feels more DDD-ish. You read the book, learn about Aggregates and Value Objects and Domain Services, and immediately create folders named after these patterns. It feels like you're doing DDD properly — look, the patterns are right there in the folder names. But DDD's central message was never "categorize things by pattern." It was "align your software with the business domain."
When I restructured projects from technical to business sub-modules, something shifted in how teams talked about the code. Instead of "I added a new Value Object to the ValueObjects folder," developers started saying "I added a new concept to the Wallet module." The directory structure started doing what the Ubiquitous Language was always supposed to do — it shaped how people thought about the system.
Your folder tree is the first piece of documentation a developer reads, and unlike a wiki page, it never goes stale. Make it speak the language of the business, not the language of the DDD textbook.