Do DDD and BPM Fit Together? The Tactical Perspective
Introduction: From Boundaries to Behavior
In the first part of this series, I argued that DDD and BPM are not competitors but two perspectives on the same domain: DDD looks at it as a structure: concepts, rules, boundaries, lifecycles. BPM looks at it as a flow: sequences, decisions, and handovers. And I took a position that some readers may find provocative: the process model is part of the domain. The engine that executes it is infrastructure, in the same ring of your application as the database.
And there is a clean argument for that split. BPMN 2.0 is an open standard, defined by the OMG, with precisely specified execution semantics. The model is not tied to any vendor: it is the specification of the process, and the engine is just one possible interpreter of it. That is why the engine is genuinely exchangeable infrastructure: Camunda, Flowable, or Operaton can all execute the same standard-conformant model, and switching engines is, in principle, a change in the infrastructure ring. You would never say that about your domain logic, but you absolutely should say that about your process engine. Treating the model as infrastructure would blur exactly this line: it would tie your description of how the business works to whatever currently executes it.
That settles the strategic question. But it leaves the tactical one open: what does this mean in practice? How do BPMN elements and tactical DDD patterns actually relate when you sit down and model a process? Which parts of your domain end up as steps in the diagram, and which ones absolutely should not?
The Running Example: Reserving a Charge
Consider a process every reader of the first part has already seen in miniature: reserving a charge for a production order. A planner triggers the reservation, the system checks whether the charge can be used, and if anything goes wrong, a human gets involved to decide how to proceed. In BPMN, the flow looks deceptively simple:
The reservation process, as it should be modeled: the aggregate stays a black box.
Four model elements carry the business meaning. But internally, "reserving a charge" is not one step: it is a handful of rules and state changes, from validating the charge data, checking the batch records, verifying the expiry date, and checking availability to persisting the new state and recording that the reservation happened. The central question of this article is: where do these six operations live? Inside the BPMN diagram, or inside the domain code, the aggregate and its application service?
Aggregates and Process Instances: Who Owns Which State?
The most important mapping to get right is also the one teams most often get wrong: the relationship between an aggregate and a process instance.
An aggregate is a cluster of entities with a clear boundary and a single root: the Charge with its batch records, its expiry date, its reservation state. Its defining property is that it enforces its own invariants: it refuses to reserve itself if the expiry date has passed, no matter who asks. The rules live with the state they protect.
A process instance, in turn, has its own state: the current flow position, the variables, the history of which path was taken. That state exists for exactly one purpose: coordinating work across the domain. It knows where in the flow a charge reservation is; it must never know whether a charge is valid.
The mapping that follows from this:
| Concern | Where it lives |
|---|---|
| Where does business state live (e.g., "charge is reserved")? | In the aggregate |
| Where does flow state live (e.g., "reservation step completed, awaiting confirmation")? | In the process instance |
| Where do business rules live? | In the aggregate (invariants) |
| Where do sequencing decisions live? | In the process model |
| What happens if a rule is violated? | The aggregate rejects the operation, and the process reacts to that outcome |
In code, the separation looks like this, deliberately unexciting, and that is the point:
public class Charge extends Aggregate {
private final ChargeId id;
private ChargeState state;
private OrderId reservedForOrderId;
private final ExpiryDate expiryDate;
public ChargeReservedEvent reserve(OrderId orderId) {
if (expiryDate.isPassed()) {
throw new ChargeRejectedException("Charge " + id.value() + " is expired");
}
if (state != ChargeState.AVAILABLE) {
throw new ChargeRejectedException("Charge is not available");
}
this.state = ChargeState.RESERVED;
this.reservedForOrderId = orderId;
return new ChargeReservedEvent(this.id, orderId);
}
}
Excerpt from the Charge aggregate: the reservation operation and the invariants it enforces.
One note on the naming: the method returns ChargeReservedEvent, not void. The aggregate records the fact that the reservation happened, but handling the event is not a task of the domain model: an application service, for example, has to do that. I add the Event suffix to make the stereotype explicit in code; the name itself, charge reserved, stays in the ubiquitous language. For simplicity, the code uses a single rejection type; in practice you may want to distinguish reasons (expired vs. not available), so a gateway can react differently.
The process engine calls through a port, the aggregate decides, the process reacts. Neither reaches into the other's state. If tomorrow a second process, or a plain REST endpoint, also needs to reserve charges, the rules exist exactly once, in the aggregate. That is the same reasoning as in the first part, one level down: boundaries, not just between contexts, but between flow and structure.
Aggregates as Black Boxes: The Granularity Question
Back to the six internal operations of "reserve a charge". Here is the version I sometimes find in real projects, modeled with the best of intentions:
The same reservation, blown open: aggregate internals modeled as flow steps.
This is what I call technical plumbing in the process model (steps that exist for the implementation, not for the business), and it fails in three ways:
- Wrong ownership of invariants. Validation, expiry, and availability are invariants: rules that hold unconditionally, for every caller, whether a process engine triggers them, a REST call does, or a batch job. Modeling them as flow steps makes them process property, so every other caller has to re-model them, and one of those copies will eventually be forgotten.
- Model bloat. Every internal rule change (say, the expiry policy changes) forces a change to the executable model itself. Of course, changing the rule in code would also require a change and a deployment; the difference is what has to change: with a clean black box, the expiry policy is one rule in one aggregate, touched once, and every process automatically uses the new version. Modeled as flow steps, the same change has to be hunted down and replicated in every diagram that reserved charges, and the executable model degenerates into an unmaintainable copy of implementation details.
- Boundary blur. A domain expert reading the diagram can no longer tell business flow from technical detail. And that is precisely how the model-authority problem from part one creeps back in: the model becomes something only developers can change, because only they understand it.
The better model is the four-element version from above. The aggregate is a black box in the process: one service task, Reserve Charge, delegating to the aggregate's reserve() method. Everything inside (validation, checks, state change) is invisible to the diagram, exactly like method internals are invisible in a component diagram.
One could object: wrap the validation and checks in a reusable sub-process, embed it via a call activity, and the duplication problem disappears, one definition, referenced by every diagram that needs it. That is true, and call activities are the right tool for reusing process logic. But it does not resolve the ownership problem: the expiry rule would still live in the process layer, so a REST endpoint or a batch job that reserves charges does not pass through it. The black box would exist, but in the wrong place, hiding invariants in the flow instead of in the domain where they protect the business object. Reuse is not the issue here; the issue is who owns the rule.
But the black box still reports back. The aggregate's outcome, the ChargeReservedEvent or its rejection, is written back to the process, and a gateway can react to it: reservation failed, so clarify with the planner; reservation succeeded, so continue with the order. That reaction is not leakage, it is exactly what the process is for. Which brings me to a distinction the blown-open diagram ignores:
- An invariant holds unconditionally, regardless of who triggers the operation or in which context. A charge cannot be reserved when expired. It belongs in the aggregate, where it protects the consistency of the business object itself.
- A business decision is contextual: the same fact can lead to different reactions in different processes. Reservation failed: in this process, we clarify with the planner; in that process, we simply pick the next charge. It belongs in the process model, where it is visible as the coordination it is.
And that visibility is a second argument, equally important, and it cuts in both directions. The process model is part of the domain: it is the visible representation of the business, its rules and its policies. Decisions hidden in code are not easily monitored, questioned, or improved by the business: if the "clarify with the planner" branch is buried in a service's if-statement, the domain expert cannot see that this policy exists, let alone confirm it is still correct. So invariants buried in the diagram are noise, and decisions buried in code are invisible business requirements. Rules have a right place, and transparency only works when they sit in it.
This gives a decision rule I have found reliable in projects: a step belongs in the BPMN diagram if and only if it is business-relevant coordination, something a domain expert would care about sequencing, deciding, or being responsible for. A step is aggregate-internal if it is domain logic: a rule or state change that holds regardless of which process triggers it.
Note that coordination includes the time dimension. Escalation rules, deadlines, and notifications are process concerns, not aggregate logic: "raise the priority of an open task after three days" or "notify the planner when a charge is still unconfirmed at noon" make no sense as invariants of a Charge. They only make sense in a flow context, and they belong in the diagram explicitly: a timer boundary event, an escalation path, a notification task. Modeling them is not technical plumbing. If we leave such rules to a hidden job scheduler or a buried conditional, we are hiding business requirements: the diagram pretends the business simply waits, when in fact it does not. The same transparency argument applies in both directions: no technical detail in the flow, but also no business rule under the floorboards.
One more thing, and it matters: the black box works both ways. The aggregate must not know it is being driven by a process engine. No process-instance IDs in the aggregate, no engine API calls, no correlation between the aggregate's fields and BPMN variables. The process is a client of the domain, calling through a port, never part of it. As established in part one: the model is domain, the engine is infrastructure, and the coupling runs only through that port. Teams that get this right can later replace their process engine (a Camunda 7 to 8 migration, for instance) without touching a single aggregate. Teams that get it wrong migrate their domain model along with their engine. I have seen both.
Domain Events and BPMN Events: Two Event Concepts, One Vocabulary
Both worlds have an event concept, and they are frequently confused, so let's be precise.
A Domain Event is a statement of fact: something happened that matters to the business. ChargeReservedEvent is a domain event. It is immutable, expressed in the ubiquitous language, and it is the same event no matter who consumed it.
A BPMN event is a flow construct: it says something about the process's reaction, like a message start event that triggers a new instance, a boundary event that interrupts a task, or an event subprocess that waits. BPMN events don't say what happened in the domain; they say what the process does about it.
The two meet at a well-defined point: when a domain event is relevant to the coordination of a flow, it becomes a BPMN message event, with the engine's correlation doing the wiring. For instance, when the ChargeReservedEvent domain event must move a waiting process instance forward, the engine receives it as a message and correlates it to the instance.
With the naming and reaction rules above in place, one boundary remains to draw: not every domain event belongs in a diagram. Publishing ChargeReservedEvent for other bounded contexts is not a process concern at all: that is integration, and it happens in the infrastructure ring. The BPMN model only consumes the events it needs for coordination.
Keeping the Language Consistent Between Model and Code
There is a final, quieter failure mode: everything is correctly separated and bounded, but the names drift. The workshop says reserve charge, the BPMN task says Create Reservation, the code says allocateCharge(), and the domain event is published as ChargeAllocated. Four names for one concept, and the ubiquitous language, the whole point of part one, is gone.
A few practices that prevent the drift; they are small, but they only work if applied consistently:
- Task labels are domain statements. Verb plus domain noun, in the ubiquitous language:
Reserve Charge,Clarify Reservation With Planner. Never engine vocabulary (Call Listener), never CRUD vocabulary (Insert Charge). - Domain event names appear unchanged everywhere. If the event is
ChargeReservedEvent, the BPMN message is namedChargeReservedEvent, and the log entry saysChargeReservedEvent. Renaming is a domain decision, done once, everywhere. - Process variables carry domain concepts.
chargeId, notvar1;reservationOutcome, notflag_2. The process variables are the contract between the flow and the task implementations, and they belong to the same language. - Review the model like code. Since the executable model is part of the running system, name quality deserves the same review discipline as a pull request. If a domain expert cannot read the task names aloud and recognize their own work, the names are wrong.
None of this requires a tool. It requires treating the diagram as what it is: executable domain language. And all of these naming failures have the same root cause as the adapter-first trap: technology dictates the vocabulary instead of the domain.
Conclusion: One Domain, Two Perspectives, Now in Code
In the first part, DDD and BPM turned out to be two perspectives on the same domain: structure and flow. In this part, the two perspectives converged into working rules:
- Aggregates own business state and rules; process instances own flow state. The aggregate enforces its invariants; the process coordinates.
- Aggregates are black boxes in the diagram. One service task per business operation, domain logic behind the boundary. A step belongs in the BPMN model if it is business-relevant coordination; it is aggregate-internal if it is domain logic. And the black box works both ways: the aggregate never knows a process engine is driving it.
- Domain events and BPMN events are different concepts that meet at the message boundary. Name the message after the event, and keep engine reaction logic out of the event's shape.
- Anti-patterns have a signature: technology where the domain should be. Meaningless IDs, REST calls, table updates, and gateways encoding return codes are all the same disease: the adapter-first trap, in diagram form.
- The ubiquitous language holds the whole thing together. Task labels, event names, IDs, variables, all one vocabulary, reviewed like code.
With the strategic perspective from part one and these tactical rules, you have everything needed to model and build a process application whose diagrams stay readable for the business and correct for the engine. There is more to say: about what happens when such systems grow, and about what remains invisible at runtime even with clean models. Maybe we'll pick up some of those threads in a part three.