When I started working with DDD, the hardest part was not the definition itself. It was recognizing the moment when it was actually useful.
Plain CRUD feels sufficient for a long time. There is a form, a controller, a model, and a few database fields. Create a record, read it, update it, delete it.
The problem does not begin when a project becomes large. It begins when a record stops being just a collection of fields and starts carrying rules.
That is exactly what I encountered while working on AVN Center. In a previous practical DDD article I showed one specific example. Here I want to explain the transition from CRUD to a domain model without unnecessary theory.
CRUD Itself Is Not the Problem
public function update( UpdateProductRequest $request, Product $product,): RedirectResponse { $product->update( $request->validated(), ); return back();}I do not replace CRUD just because DDD sounds more sophisticated or architectural.
If an entity is genuinely simple, fields can change independently, and there are no meaningful state-transition rules, plain CRUD is often the best solution.
Extra abstraction would only make that code harder.
The Problem Starts When Update No Longer Means Change Fields
At first: namedescriptionstatus Later: - status can change only in a valid order- activation requires complete data- some fields become immutable after activation- one action depends on another state- deletion is not always allowedI saw exactly this progression in AVN Center.
At first, an object could be treated as a simple record with a few fields. Later, states appeared, transitions became constrained, actions depended on other conditions, and some operations were no longer always allowed.
I intentionally avoid exposing the internal module map or exact product rules here. The important experience is broader: once rules accumulate, they stop describing a table and start describing the domain itself.
My First Signal: the Controller Starts Knowing Too Much
public function activate( Product $product,): RedirectResponse { if ($product->status !== 'draft') { abort(422); } if ( blank($product->name) || blank($product->code) ) { abort(422); } if ($product->is_blocked) { abort(422);This code does not look terrible yet, but it is already answering important questions.
From which state may the object be activated? Which data is required? Which condition blocks the action?
Those are not HTTP questions. They are domain rules.
If the same action later appears in a command, queue, admin interface, or another use case, those checks will either be duplicated or extracted somewhere else.
I Want the Object to Know What Activate Means
final class Product{ public function activate(): void { if ($this->status !== ProductStatus::DRAFT) { throw new DomainException( 'Product cannot be activated from this state.', ); } if (! $this->isReadyForActivation()) { throw new DomainException( 'Product is not ready for activation.', ); } External code no longer decides which fields or states make activation valid.
It simply asks the object to perform the action.
The domain model either performs a valid transition or rejects it.
This was one of the most useful shifts in how I understood DDD: instead of thinking "update status," I think "perform an action that has rules."
The Application Service Coordinates the Use Case
public function handle( ProductId $id,): void { DB::transaction(function () use ($id) { $product = $this->products ->getForUpdate($id); $product->activate(); $this->products->save( $product, ); });}The outer service does what naturally belongs there: opens a transaction, loads state, invokes the action, and persists the result.
It does not contain the list of conditions that make activation valid.
This is a simple but important boundary: coordinating a use case and defining a domain rule are not the same responsibility.
The Turning Point Is a Rule That Must Always Hold
DDD often uses the word "invariant" for this kind of rule. In simple terms, it is a condition that must remain true after every valid operation.
For example: an active object must have required data, or a certain state transition cannot happen twice.
Once rules like these exist, I no longer want to rely on every controller remembering the correct checks.
Some rules can also be reinforced lower in the stack. I covered that separately in an article about database constraints as the last line of defense.
Status Stops Being Just a String in a Table
draft | vready | vactive | vsuspended Not every transitionis allowed in both directions.In plain CRUD, a status field is easy to treat as just another string.
In the domain it often means much more: which actions are currently allowed, which data must already exist, and which transition can happen next.
Instead of arbitrary update(["status" => ...]), I prefer explicit actions such as activate(), suspend(), or restore() when those actions carry distinct rules.
Not Every Condition Means You Need DDD
A string-length check, email-format validation, or basic request validation does not automatically justify a separate domain model.
I look at the meaning of a condition, not the number of if statements.
If a condition expresses a domain rule that must remain the same regardless of where the action is invoked, that is a strong signal.
DDD Does Not Start with Folder Structure
Domain/Application/Infrastructure/Http/You can create a beautiful Domain/Application/Infrastructure structure and still leave every rule inside controllers.
And the opposite is also true: you can start with a small model where one important action and its rules are kept together and already get practical value.
I now start not with "which folders should I create?" but with "which rule must the system make difficult to break?"
One Action Does Not Mean Everything Belongs in One Huge Use Case
DDD also does not mean one business action should absorb every secondary process.
I try to separate what must be consistent immediately from what can happen after the state change succeeds.
This connects to another practical topic I have written about: when one transaction is better than an event.
My Practical Threshold for Moving Beyond CRUD
I start seriously considering a domain model when several signals appear together:
- state has meaning rather than being just another stored field;
- not every state transition is allowed;
- the same action is invoked from several places;
- the action needs rules unrelated to HTTP or form handling;
- a failure can leave the system in an invalid state;
- developers can no longer understand the rule by looking at the controller alone;
- a concrete action name communicates intent better than a generic
update().
When I Still Keep Plain CRUD
CRUD remains the right choice for me when:
- the object mainly stores data;
- fields can change independently;
- there are no meaningful state transitions;
- there are no rules that must be protected consistently across several use cases;
- a separate behavioral model would not make the code easier to understand.
This is also part of a DDD mindset for me: do not model complexity that does not exist.
What Working on AVN Center Changed in My Approach
Earlier, I was more likely to approach a new feature through its table structure and the list of required operations.
Now, in more complex parts of a system, I first ask:
- which states are valid;
- which transitions are allowed;
- which rule must always remain true;
- who is allowed to change this state;
- what must happen in one consistent operation and what can happen later.
These questions do not automatically require a heavy architecture. They simply reveal very quickly where plain CRUD has stopped being enough.
DDD Becomes Useful Because of Rules, Not Project Size
For me, the boundary between CRUD and DDD is now fairly simple.
While the system mainly stores data, CRUD works very well.
Once the system starts protecting rules, states, and valid transitions, simply updating fields is no longer enough.
That is when a domain model becomes useful, using action-oriented language such as activate, suspend, approve, reject, or complete.
For me, DDD does not begin with an architecture diagram. It begins when protecting a rule becomes more important than merely storing a record.
If the controller is already explaining what a state means and when an action is allowed, it may be time to ask whether that rule belongs in the domain itself.



