Repository navigation
Workflow aggregates
A workflow needs data. In VanillaBP that data lives in one entity per workflow, the workflow aggregate. Here is the short version. Write an ordinary entity for your process, let the expressions in your BPMN (Business Process Model and Notation) models read its getters, and store it the way your application stores everything else. The rest of this page explains why it works that way and what you can control.
-
What is an "Aggregate"?
The entity itself with and without JPA, what the aggregate root is, and a second workflow on the same entity. -
Why is this concept used in VanillaBP?
Why a BPMS needs data, and why one entity works for every BPMS. -
Aggregate persistence
What works out of the box, how to plug in your own storage, and whose transaction the work runs in. -
Decoupling BPMN from the data model
Getters which name an intention, how they survive a change of the data model, and what the start says about an expression which does more than name a getter. -
The data objects the aggregate replaced
What happens to a BPMN data object, and where the data store of the BPMN specification is. -
Fine-grained control over attributes synchronized to the BPMS
What the BPMS gets to see, what the annotations decide, and when the values are handed over. -
Two writers on one aggregate
Which pairs of writers can meet on one aggregate, and which way out works for each pair.
A workflow needs data to do its job: customer IDs, order IDs, the items ordered. More data turns up while it runs, for example the shipping status. All of this data belongs to exactly one workflow (i.e. one running BPMN process). The VanillaBP SPI (service provider interface) stores it in one entity per workflow. In terms of DDD (domain-driven design), that entity is an aggregate:
@Entity
@Table(name = "ORDER")
@Getter
@Setter
public class Order {
@Id
private long orderId;
private long customerId;
private float totalPrice;
@Enumerated(EnumType.ORDINAL)
private ShippingStatus shippingStatus;
}If you use JPA (Jakarta Persistence API) to store it, the aggregate may be split into several entities, for example with one-to-many or many-to-one relationships. The aggregate root is still the one entity which belongs directly to the workflow:
@Entity
@Table(name = "ORDER_ITEM")
@Getter
@Setter
public class OrderItem {
@Id
@ManyToOne
private Order order;
@Id
private long itemId;
private int amount;
private float itemPrice;
@Enumerated(EnumType.ORDINAL)
private ShippingStatus shippingStatus;
}
@Entity
@Table(name = "ORDER")
@Getter
@Setter
public class Order {
@Id
private long orderId;
private long customerId;
private float totalPrice;
@OneToMany(mappedBy="orderId")
private Set<OrderItem> items;
}The example uses JPA, but VanillaBP does not need it. Any Java class and any persistence technology will do. Aggregate persistence explains how.
An aggregate may live longer than its workflow. When that workflow has ended, you can call
startWorkflow for the same aggregate again, and a second workflow runs on it. VanillaBP supports
this, but we do not recommend it.
Sometimes the same business id is processed twice, for example when an order is placed a second time. Then two aggregates are usually the better model. Each workflow has its own data, its own history and its own tasks. Nothing the first workflow left behind can be mistaken for data of the second one.
A BPMS (business process management system) needs data to make decisions while it runs a workflow. Say a BPMN model has two paths after a customer's payment. The BPMS has to know which one to take. The application which handles the business events (e.g. payment completed) has that information, and it has to pass it to the BPMS. Each BPMS has its own way to do this. One takes a list of attributes, another takes an object tree per workflow. (why)
VanillaBP follows hexagonal architecture. So it needs one common way to handle this data, the same for every BPMS.
This is what the aggregate is for. It holds exactly the data a workflow needs. How to handle such data is a solved problem: entities, the same way applications without a BPMS store their data.
VanillaBP uses those entities for persistence. As hexagonal architecture asks, the adapters copy the data to the BPMS wherever that is needed. The data itself stays with the application, as if there were no BPMS. Conditions in BPMN are expressions in the language of each BPMS, and they refer to the attributes of the aggregate:
-
JUEL:
${orderId}-
${items[1].itemId}= the second item'sitemId
-
FEEL:
orderId-
items[2].itemId= the second item'sitemId
Your application creates aggregates when it needs them, loads them when it reads them and saves them when they change. In some situations, though, VanillaBP has to do this for your application.
The typical case is a service task. The BPMS reaches it, and the code behind it has to run. VanillaBP calls the method annotated with @WorkflowTask and passes the aggregate as a parameter. So VanillaBP must be able to load the aggregate.
In other situations VanillaBP has to save the aggregate. For example, when a workflow starts, its data has to be stored before the first BPMN task runs.
Both mean that the aggregate has to exist for as long as its workflow runs. Say a task is delivered for a workflow whose aggregate is not in your database. VanillaBP then has nothing to pass to your method, so it refuses the delivery. The transaction is rolled back and the BPMS sees a failed delivery. Once its retries are used up, this becomes an incident, and the workflow stays where it is until somebody puts the data back. The message names the aggregate class, the ID it looked for, the adapter, the task, the BPMN process, the workflow module and the workflow. It also names the two possible causes, because nothing in the code can tell them apart. Either your application deleted the aggregate while the workflow was running. Or the workflow belongs to another application which shares this BPMS, and the task reached you by mistake, see somebody else's work arriving here. The right moment to delete an aggregate is the end of its workflow. A @WorkflowEnded method may delete it. If a notification arrives later for an aggregate which is already gone, VanillaBP logs it and skips it. It is not treated as an error.
Depending on your platform and persistence technology, you may not have to do anything, because VanillaBP does it for you. This works out of the box for Spring Boot applications which use Spring Data (JPA or MongoDB). It also works for Quarkus applications which use Panache (JPA or MongoDB, repository or active record) or Spring Data repositories. Your platform integration has the details.
If your persistence technology is not supported yet, write a component (Spring bean / CDI bean) which implements the interface io.vanillabp.integration.spi.AggregatePersistenceAware<A> for the aggregate in question:
public interface AggregatePersistenceAware<A> {
Class<A> getAggregateClass();
A save(A aggregate);
Object getAggregateId(A aggregate);
A loadById(Object aggregateId);
String getAggregateIdName(); // needed only by adapters storing the ID in the BPMS (e.g. Camunda 8)
Class<?> getAggregateIdType(); // default: detected by reflection (@Id-annotated or 'id'-named member)
}All methods are default methods which throw an exception with a helpful message. So your implementation still compiles when new methods are added. Implement every method VanillaBP really uses for your aggregates. getAggregateIdType() is different: its default finds the ID type by reflection and does not throw. The ID of the aggregate passes through VanillaBP's transaction outbox as a String. So the type has to convert to text and back without losing anything (String, boxed numbers, BigInteger/BigDecimal, Boolean or UUID, checked at startup). If your persistence layer handles the text form itself, override getAggregateIdType() to return null. Then the String is passed on unchanged.
The text form of the ID may have up to 1024 characters. The SQL tables VanillaBP writes keep it in a column AGGREGATE_ID of that width, and a longer ID is refused with a message before anything is written. Oracle and DB2 count that column in bytes, not in characters. There an ID with characters outside ASCII, like an umlaut or an emoji, can pass the check and still be longer than 1024 bytes. Then the insert fails. The schema README says how to widen the column. The keys VanillaBP builds from the ID are measured in bytes and hashed when they get too long, so they fit on every database.
One implementation can serve a whole workflow module. The class returned by getAggregateClass() does not have to be an aggregate. It may be a superclass or an interface which every aggregate of the module implements. If several implementations cover the same aggregate, the most specific one wins. An empty marker interface is enough. So a module gets its own persistence without one class per aggregate:
public interface LedgerAggregate { } // no methods, implemented by the module's aggregates
@ApplicationScoped // or @Bean on Spring Boot
public class LedgerPersistence implements AggregatePersistenceAware<LedgerAggregate> {
public Class<LedgerAggregate> getAggregateClass() { return LedgerAggregate.class; }
// ...
}An implementation which names a single aggregate class still wins over the marker one, because it is closer to the aggregate. If two implementations are at the same distance, the startup ends and names both. Which persistence VanillaBP uses must not depend on the order in which beans are found.
When VanillaBP calls you, it owns a transaction. Your implementation is called from three kinds of threads. One is the thread your API call runs on. One is the adapter thread which delivers a BPMN task. One is the dispatcher thread of the transaction outbox, which does the second phase of a workflow start. The last two threads belong to VanillaBP. So VanillaBP opens the transaction before it calls you. On Quarkus it also opens the CDI request context, because without it an entity manager cannot be used. You never have to annotate your persistence with @Transactional for VanillaBP.
You decide whose transaction it is. By default it is the platform's: JTA on Quarkus, the transaction manager of your application on Spring Boot. Maybe your aggregates live in a system the platform does not manage. Then you provide the unit of work yourself. Implement TransactionRunner and register it as a bean. It then serves every aggregate of the application:
@ApplicationScoped // or @Bean on Spring Boot
public class LedgerTransactions implements TransactionRunner {
public <T> T requireNew(Supplier<T> work) { /* open, run, commit, roll back on exception */ }
public <T> T inCurrent(Supplier<T> work) { /* join what is already open */ }
public boolean isRollbackOnly() { return false; }
public boolean isTransactionActive() { /* is one of mine open on this thread? */ }
}An application with more than one kind of persistence assigns runners per aggregate instead, with a TransactionRunnerAware bean. The aggregate class it names may be a superclass or an interface which all aggregates of a workflow module implement. The most specific bean wins. So one bean can serve a whole module while another one names a single aggregate:
@ApplicationScoped
public class OrderTransactions implements TransactionRunnerAware<HasWorkflowState> {
public Class<HasWorkflowState> getAggregateClass() { return HasWorkflowState.class; }
public TransactionRunner getTransactionRunner() { return ledgerTransactions; }
}If two beans cover the same aggregate at the same distance, the startup ends and names both. A transaction boundary must not depend on the order in which beans are found.
VanillaBP may ask your unit of work to run a check right before it commits. In phase one, an adapter is asked whether an operation still makes sense. Is the task still there? Does the model know this message? That answer can become outdated between the question and the moment VanillaBP acts on it. The later the check runs, the smaller that gap. So the adapter hands the check to the runner of the aggregate, and a check which throws stops the commit. Such a check asks once and never waits, because your transaction is open while it runs. Implement beforeCommit(Runnable) if your unit of work can offer this:
@Override
public void beforeCommit(Runnable check) {
unitOfWork.registerBeforeCommit(check); // your own hook
}The method has a default which runs the check at once. So a runner which does not implement it is still correct. The gap is just as wide as before. Both platform runners implement it (Spring Boot with a TransactionSynchronization, Quarkus with an interposed JTA Synchronization).
What the platforms bring, and what they need. The table shows the condition for each case and what VanillaBP tells you at startup:
| Persistence | Transaction | Condition |
|---|---|---|
| JPA, JDBC (Spring Boot), Hibernate/Panache (Quarkus) | the platform's, nothing to do | a transaction manager exists; it comes with the starter or the extension |
| MongoDB on Quarkus (Panache) | MongoDB Panache starts a MongoDB transaction inside the JTA one, and VanillaBP's outbox and delivery log write in that same session | the deployment is a replica set (a replica set with a single node is enough). On a standalone server every write inside a transaction fails, and VanillaBP warns about it at startup |
| MongoDB on Spring Boot | the MongoTransactionManager, which MongoTemplate and the MongoDB repositories use |
you define that bean yourself, because Spring Boot does not configure one, and the deployment is a replica set. Without the bean the startup ends with a message which names it. A MongoDB aggregate in an application whose only manager is a JPA one ends the startup as well |
| a storage of your own (event store, ledger, message producer, an API) | yours, through TransactionRunner
|
nothing else: the application needs no data source and no MongoDB |
| several of the above in one application | one runner per aggregate | assign them with TransactionRunnerAware beans. With two managers around, VanillaBP cannot pick one by itself |
Where VanillaBP can see that a store is not covered by a transaction, it tells you at startup. If the guarantees are only weaker, you get a WARN. If the combination cannot work and there is a known fix, the application does not start. An application which accepts the weaker behaviour says so: it sets vanillabp.transactions.unguarded-aggregate-writes to accepted, for the whole application or for one workflow module (vanillabp.workflow-modules.<id>.transactions.unguarded-aggregate-writes). The message then stays a warning. VanillaBP says nothing about what it cannot judge, such as an AggregatePersistenceAware implementation which writes wherever it wants.
Without a transaction around the store, these are the guarantees which remain, per store:
| Store | What a crash between the writes leaves behind | Recovered by | What stays true |
|---|---|---|---|
| the workflow aggregate | the aggregate is written although the work around it failed. A rollback does not undo it | nothing. The next delivery reads the written state | a version conflict is still detected, and a repeated delivery still finds a consistent aggregate |
| the phase-two outbox | an entry whose aggregate was never committed | the dispatcher: it fails again and again, then the entry goes to BLOCKED with an ERROR you can monitor | the phase-two call happens at least once, and no start is lost as long as the entry survives |
| the log of processed deliveries | a record for work which was rolled back, so the redelivery of that work is skipped | the compensating delete of both MongoDB defaults, unless the process dies in between | removing duplicates is best effort only. The standing rule to base decisions on the state of the aggregate covers this case |
One combination cannot be fixed at all. An embedded Camunda 7 needs a relational database, so the transaction of its engine can never cover an aggregate stored somewhere else. An application which combines the two accepts that the engine and the aggregate commit separately.
This interface does not depend on the platform. You implement the same interface on Spring Boot and on Quarkus. It is part of the integration SPI io.vanillabp:vanillabp-integration-spi. You get it through the platform support module you already use for workflow modules (io.vanillabp:vanillabp-spring-boot-support / io.vanillabp:vanillabp-quarkus-support), so you need no extra dependency. If more than one implementation matches an aggregate, the one with the most specific generic aggregate type wins (superclasses and implemented interfaces count).
Why is this concept used in VanillaBP? showed that BPMN expressions can read the attributes of an aggregate directly. That works, but it ties your data model closely to the BPMN. Your code and the process definition then share a contract, and you have to keep both in step. During the first implementation this is fine. It becomes a problem once requirements change.
Take a process which packs items for shipment. For years your shipping partner knew only normal and big packages. So your aggregate has a property boolean bigItem, and the BPMN picks the path with expressions like ${bigItem} and ${not bigItem}. This works well until the pricing model changes. Now the shipping partner has normal, medium and big packages.
You know the item sizes anyway, so you change your aggregate from boolean bigItem to ItemSize size. ItemSize is an enum with the values Normal, Medium and Big. Updating the database is easy, e.g. with a changeset in Liquibase. But what about the workflows which are already running?
When you roll out the new version of your software, some orders are in progress. Their workflows still expect the old attribute bigItem. Now you hope that your BPMS can migrate workflow data. Sadly this is neither standardized nor easy to use. Usually you have to stop BPMN processing, deploy the new version, migrate the running workflows and then resume, if your BPMS supports all of this at all. The procedure is easy to get wrong, and it is worth avoiding.
You avoid the problem if you think again about what data a BPMS needs to run a workflow. In practice it needs only yes-or-no decisions. Every gateway comes down to the question "should this path be taken?". An exclusive gateway with three outgoing paths is three such decisions.
So do not let the BPMN read raw properties of the aggregate. Write those decisions down as getters on your aggregate:
public class Order {
...
private boolean bigItem;
...
public boolean isShippedAsNormalItem() { return !bigItem; }
public boolean isShippedAsBigItem() { return bigItem; }
}Your BPMN then reads those getters, e.g. ${shippedAsNormalItem}. Moving the data model to ItemSize later is simple:
public class Order {
...
private ItemSize size;
...
public boolean isShippedAsNormalItem() { return size == ItemSize.NORMAL; }
public boolean isShippedAsMediumItem() { return size == ItemSize.MEDIUM; }
public boolean isShippedAsBigItem() { return size == ItemSize.BIG; }
}The existing BPMN expression ${shippedAsNormalItem} still works, although the data model below it changed. Also, the decisions in your BPMN become easy to read, because each getter says what it means. It is a small technique, and it is the one we recommend.
There is one exception to this pattern: BPMN multi-instance constructs. Some BPMS only need a plain number which says how often a multi-instance task runs. For an expression like ${numberOfItems} this getter is enough:
public class Order {
...
Set<OrderItem> items;
...
public int getNumberOfItems() { return items == null ? 0 : items.size(); }
}Other BPMS need a real collection. For an expression like ${itemIds}, add a getter which returns a list:
public class Order {
...
Set<OrderItem> items;
...
public List<Long> getItemIds() {
return items == null
? List.of()
: items.stream().map(OrderItem::getItemId).toList(); }
}If this drives a BPMN multi-instance embedded subprocess, some expressions may have to read single items, e.g. ${orderItems[1].shippedAsNormalItem}. Add data transfer objects (DTOs) and mappers for that:
public class Order {
...
Set<OrderItem> items;
...
@Getter
@Setter
public static class Item {
private ItemSize size;
public boolean isShippedAsNormalItem() { return size == ItemSize.NORMAL; }
public boolean isShippedAsMediumItem() { return size == ItemSize.MEDIUM; }
public boolean isShippedAsBigItem() { return size == ItemSize.BIG; }
}
public List<Item> getOrderItems() {
return items == null
? List.of()
// e.g. see https://mapstruct.org/documentation/stable/reference/html/#mapping-collections
// and https://mapstruct.org/documentation/stable/reference/html/#mappers-factory
: OrderItemMapper.INSTANCE.toItems(items);
}
}${orderItems[1].shippedAsNormalItem} is a path, and the start names it like any other path. The message also says that a path into one item of a multi-instance subprocess is the one exception on this page. If your BPMS needs such a path, keep it. The item at the end of it is still a DTO with getters which name an intention, so your domain model can change below it.
This keeps the BPMN expressions stable and easy to read. And the workflow does not depend on the details of your domain model.
Sections of a larger process need the same care. A section drawn as an embedded subprocess or as a call activity usually keeps its results in an object of its own on the aggregate. That object is null until the section runs. In a workflow which skips the section, it stays that way. Never let an expression read a path into such an object. ${creditCheck.acceptable} cannot be resolved for a workflow which never did a credit check. VanillaBP stops that workflow and names the path and the part of it which is empty. It does not hand your model a false which nobody computed. Ask the aggregate instead:
public class Order {
...
private CreditCheck creditCheck; // null until that section has run
...
public boolean isCreditChecked() { return creditCheck != null; }
public boolean isCreditAcceptable() {
return creditCheck != null && creditCheck.isAcceptable(); }
}Whether a section ran is a fact about the workflow. So the model may ask about it, and isCreditChecked() gives the answer. Inside the section, its own model may read the results of the section, because there the object exists. If a value really has to be there before its section ran, declare a substitute for that path in the configuration of the workflow. Then the value is stated, not guessed. The blueprint bpmn-process-sections (Spring Boot, Quarkus) runs a process with two sections. It shows the getters and also the case which goes wrong.
We measured one thing while we built that blueprint, and you will run into it too. A gateway placed directly after a call activity cannot read an existence getter. What a called process shares stays in the child instance. So the calling process still sees the old value when the call activity returns. Put a task of the calling process in between. Or draw the optional section as an embedded subprocess, which writes the variables of the instance around it.
Since VanillaBP 2.0 the start reads the expressions of your BPMN models. It names every expression which does more than name one variable, and the message points back to this page. Each expression is sorted by what it does:
| The expression | Example | What the start says |
|---|---|---|
| names one variable | ${shippedAsNormalItem} |
nothing |
| walks a path |
${order.shipping.express}, ${orderItems[1].shippedAsNormalItem}
|
a warning |
| calls something |
${count(items) > 3}, ${order.getShipping().isExpress()}
|
a warning |
| computes |
${not bigItem}, ${amount > 1000}
|
a notice, logged at INFO |
A path or a call is a warning, because it ties the model to the shape of your Java objects. Rename an attribute, and a model nobody touched stops working. A computation is only a notice. It leaves your data model alone, but it ties you to the expression language of the BPMS, and the word for "not" differs from one BPMS to the next. The way out is the same in both cases: a getter on the aggregate, and one name in the model.
The ${not bigItem} of the shipping story above is such a computation. That is why the example
has two getters. With only isShippedAsNormalItem(), the other path of the gateway would read
${not shippedAsNormalItem}, and that is a computation again. isShippedAsBigItem() answers the
second question in Java, where a test can reach it.
The start writes one message per BPMN process and log level. It lists each expression with its
element and the place in that element. It also counts how many expressions of the process already
name just a variable, so you see how far along the model is. A process which does everything right
gets no message at all. The messages are listed under BPMN MODELS in
what a start says about itself.
Neither message stops a start or a deployment. An existing model deploys as before, and you do not have to rewrite any model. A check which reads expressions can also get one wrong, and refusing a working model by mistake would be worse than a wrong warning.
Some things are not named at all:
- An expression which the BPMS resolves for itself, such as a wired task, a delegate class or a form key. These do not read your data, and no getter can replace them.
- A BPMN process which no
@WorkflowServiceclass claims. It only shares a file with a process this application serves, and nobody here can change it. - The models of an adapter which does not read its expressions yet. The Camunda 7 and the Camunda 8 adapter read them. The Process-Engine-API adapter does not yet.
This is not the check of a path which does not resolve. That check stops the start, because a declared path leads nowhere. This one only tells you what a path will cost you later. Both may report the same expression.
If the expressions of a process are meant the way they are, say so once and the messages are gone. The message gives you the line for its workflow:
vanillabp:
workflow-modules:
loan-approval:
workflows:
LoanApproval:
accept-expressions-in-the-model: trueThe same key is also read at the workflow module and at the application, and the most specific level wins. The levels of this setting shows all three.
BPMN has a place for the data of a process: the data object. A data object lives and dies with the process or subprocess it is drawn in, and only the elements around it may read it. Data which has to live longer than the process goes into a data store. The specification describes a data store as the way to keep information beyond the scope of the process.
VanillaBP solves this differently, and this page is the answer. The workflow aggregate is the data of the workflow. It lives in your database. It is there before the workflow starts and after it has ended. The BPMS sees only the part you share, and it never writes back into it. So you do not have to build the data store of the specification. Your aggregate is the data store, and the process has no data of its own.
So a data object you draw plays no part. No adapter reads it, no check reports it, and nothing in it reaches your aggregate. Both Camunda engines ignore it as well. They hold process variables with a scope, and the values in them are the ones VanillaBP writes. So a gateway which reads a data object reads nothing.
Draw one where it helps a reader of the model understand what the process is about. Do not expect it to hold anything.
As long as your aggregate has no sync-specific annotation, everything it offers through a getter is shared, with every BPMS, embedded ones included. Every engine evaluates the expressions of its models against its own variables. A model which reads something the BPMS was not given would work on one BPMS and take the default flow on the next. Sharing everything by default keeps a model portable. What "synchronized" means in detail is up to the BPMS adapter: when the values are handed over, how a nested value is stored, and what the BPMS does with new values. The wiki of each adapter explains it.
Readable means there is a getter: getX() with any return type, or isX() which returns boolean. A field without a getter is never shared. Neither is an isX() which returns something else. If a model has to read such a value, give it a getter.
You take control with the first annotation. As soon as one sync-specific annotation appears anywhere on the aggregate, the default of the adapter no longer applies and your annotations decide.
If you annotate only attributes, the behavior of the class follows from them. Naming some attributes only makes sense as a rule about the others:
- attributes marked
@SyncWithBPMSmean "share exactly these", so the class behaves as if it was annotated@NoSyncWithBPMS, - attributes marked
@NoSyncWithBPMSmean "share everything but these", so the class behaves as if it was annotated@SyncWithBPMS.
Both annotations mixed on the attributes of a class which is not annotated itself have no clear meaning. Is the rest shared or not? VanillaBP does not guess. The application does not start, and the message names the class, the conflicting attributes and the fix (annotate the class explicitly). The check runs at startup for every workflow aggregate and every nested type it can reach. So you find out at the next start, not when a workflow reaches a sync point.
One thing the annotations never decide. Some BPMS have no business key of their own. Such a BPMS receives the ID of the workflow aggregate as a technical value. That value is named after the ID attribute of the aggregate, the one your persistence reports as the identifier. VanillaBP uses it to find the workflow again, so it cannot be excluded. (why)
Why the default shares everything, although we do not recommend it. There are two reasons, and neither says that sharing everything is right. First, a model has to be portable. Say an expression reads a value the BPMS was never given. It works on an engine which can read your aggregate live, and it takes the default flow on the next engine. So the safe default is the one where every model finds what it reads. Second, an application upgrading from VanillaBP 1 has to keep running without changes to its code, and version 1 shared everything. It grew up with the embedded Camunda 7 engine, which read the whole aggregate whenever it wanted. This default is a fallback for the migration, not the goal. (why)
What to aim for instead: share as little as possible and as much as needed. Put @NoSyncWithBPMS
on the aggregate class and @SyncWithBPMS on the attributes which need it. These are of two kinds.
The first is what the process needs for its decisions: gateway conditions, multi-instance collections,
the values a correlation is matched by. The second is what somebody looking at Operate, Cockpit or the
history of the engine needs to see where a workflow went wrong. At that moment an operator cannot ask
your database. Everything else stays in your application, where it already is.
Nobody asks you to push the whole aggregate into the BPMS. Its database is yours, and your operators can read it anyway. Ignoring this has a cost you can measure, and each BPMS charges it differently. A remote BPMS carries the data with every command, so you pay for a large aggregate on every task. An embedded Camunda 7 needs a serialization format configured before it can write a nested value at all.
An aggregate which shares everything has to say so. VanillaBP does not start a workflow whose aggregate holds nothing back. The message names the workflow, the aggregate and the attributes which are shared. It offers the two ways above: annotate what the models need, or allow the full sync for that workflow.
vanillabp:
workflow-modules:
my-module:
workflows:
MyProcess:
allow-full-sync-with-bpms: trueThe permission belongs to the workflow and is not inherited. The same line at a workflow module, at the application or in an adapter section is refused, and the message says where it belongs. A permission from a higher level would silently cover the next workflow somebody adds. An aggregate which holds nothing but its ID needs no permission, because that value reaches the BPMS anyway. A secondary BPMN process is covered by the permission of the process it belongs to. (why)
Share only what is really needed. That keeps the data stored in the BPMS small, and it keeps your BPMN model as independent of your software as possible. VanillaBP has the annotations @SyncWithBPMS and @NoSyncWithBPMS for that:
@SyncWithBPMS // share everything of this aggregate ...
public class Order {
...
@NoSyncWithBPMS
private ItemSize size;
...
public boolean isShippedAsNormalItem() { return size == ItemSize.NORMAL; }
public boolean isShippedAsMediumItem() { return size == ItemSize.MEDIUM; }
public boolean isShippedAsBigItem() { return size == ItemSize.BIG; }
}The example above names the values which are not shared. The next one fits better if you follow Decoupling BPMN from the data model, because it names the values which are shared:
@NoSyncWithBPMS
public class Order {
...
private ItemSize size;
...
@SyncWithBPMS public boolean isShippedAsNormalItem() { return size == ItemSize.NORMAL; }
@SyncWithBPMS public boolean isShippedAsMediumItem() { return size == ItemSize.MEDIUM; }
@SyncWithBPMS public boolean isShippedAsBigItem() { return size == ItemSize.BIG; }
}Every attribute inherits the behavior of its owner until you override it. An attribute of the aggregate inherits from the aggregate class, and the class inherits from the default of the adapter. The attributes of a nested object inherit from the attribute which holds the object. The elements of a collection inherit from the collection. So a DTO without an annotation behaves exactly like the attribute it is used for. It is never "fully shared" on its own. An annotation on the DTO class itself overrides that wherever the DTO is used. So does the behavior which follows from its own attributes, because every type decides its base behavior the same way the aggregate does. This is how you cut the shared data down to the minimum:
@NoSyncWithBPMS
public class Order {
...
Set<OrderItem> items;
...
@NoSyncWithBPMS
@Getter
@Setter
public static class Item {
@SyncWithBPMS
private long itemId;
private ItemSize size;
@SyncWithBPMS public boolean isShippedAsNormalItem() { return size == ItemSize.NORMAL; }
@SyncWithBPMS public boolean isShippedAsMediumItem() { return size == ItemSize.MEDIUM; }
@SyncWithBPMS public boolean isShippedAsBigItem() { return size == ItemSize.BIG; }
}
@SyncWithBPMS
public List<Item> getOrderItems() {
return items == null
? List.of()
// e.g. see https://mapstruct.org/documentation/stable/reference/html/#mapping-collections
// and https://mapstruct.org/documentation/stable/reference/html/#mappers-factory
: OrderItemMapper.INSTANCE.toItems(items);
}
}The BPMS then gets a data structure like this:
{
orderItems: [
{
itemId: 4711,
shippedAsNormalItem: true,
shippedAsMediumItem: false,
shippedAsBigItem: false,
},
{
itemId: 815,
shippedAsNormalItem: false,
shippedAsMediumItem: false,
shippedAsBigItem: true,
}
]
}- Attributes are the readable JavaBean properties of your aggregate, including the getters recommended above. If a getter and a field have the same name, the annotation of the getter wins.
-
Values are converted to plain types every BPMS understands. Enums become their name. A
UUIDand ajava.timevalue become the text they write for themselves. Ajava.util.Datebecomes the instant it holds (2026-09-16T19:55:30.123Z), and ajava.util.TimeZonebecomes the id of its zone (Europe/Berlin). Collections and arrays become lists, and nested objects become maps. Every other JDK type becomes its own text, and only one way: a handler can read such a value back only as aString, and VanillaBP tells you so while your application starts. Ajava.util.Calendaris the one type VanillaBP refuses to share at all. Its text is the debug output of the implementation and carries no value. An aggregate which shares such an attribute does not start, and the message says to share anInstantinstead. Which types travel both ways is explained in What a@TaskParammay be declared as. What each BPMS then does with the values it gets is answered in the same four questions in every adapter wiki: which values it stores as they are, what the others need, and what a value loses on the way. See Camunda 7, Camunda 8, Process-Engine-API. -
Cycles are cut, not followed. Ordinary entities often point at each other in both directions (
order→item→order), so this is the normal case. An object which is already on the current path is replaced by a short reference (its type and identity). Expanding it again would repeat the whole subtree once per level. Apart from that, VanillaBP follows nesting at most ten levels deep (a workflow aggregate is not a graph database). -
Sync points are the moments when VanillaBP talks to the BPMS for a workflow: starting a workflow (also by message), the end of a
@WorkflowTaskmethod (the task is reported as completed, or as a BPMN error), completing or canceling an asynchronous task, completing or canceling a user task, and correlating a message. Everything shared is handed over at every sync point. VanillaBP does not send only the changed values, on purpose, because that would make the copy in the BPMS a second source of truth. The sync point at the end of a@WorkflowTaskmatters most for a gateway placed right after that task: the gateway evaluates what the method produced. - When the values arrive differs per BPMS. An operator needs to know this difference when a gateway right after a service task took the wrong path. Camunda 7 writes the values inside the engine's own transaction, before the activity is left. So the values and the step the workflow took commit together. Camunda 8 and the Process-Engine-API write them after your local commit, together with the command which completes the task. A failure between the commit and that command leaves your aggregate ahead of what the BPMS knows, until the outbox repeats the command. Camunda 7 does not have this gap, and this is why the comparison has that row.
- An attribute which a gateway reads has the meaning it has at the sync point, not at the moment your code wrote it. Say your application writes an attribute when it decides something. That attribute says the same thing an hour later, and a gateway which reads it takes the path you meant. Now say an attribute is computed from data which somebody else keeps changing. It says whatever it says at the next sync point, and the path follows that. Both are fine. Which kind an attribute is belongs in the aggregate, not in the model.
-
Never the other way round: the BPMS never writes back into your aggregate. The aggregate is the single source of truth. The only values VanillaBP reads from the BPMS are the ones a
@TaskParamasks for explicitly, and the BPMN model provides them on purpose.
Saying what is shared is the first half. The second half is the TYPE of each shared value, and VanillaBP checks it while your application starts.
A value of the aggregate travels so that a model can decide on it, and the expression language of the BPMS reads it. Two types mean the same in every expression language: the plain boolean and a text. Those two travel. Everything else has to be declared first. A workflow which shares an undeclared decimal, date, number, enum, collection or map does not start.
The start fails because the declaration is missing, not because of the type. Every type can be declared, and once it is, the value travels. That includes a collection and a map. Such a value travels as one value, and every expression language reads what is inside it in its own way. That is why a model which looks inside it depends on its BPMS.
The wrapper Boolean is not the plain boolean, and this is on purpose, not pedantry. A null in a gateway condition means something different on every BPMS, and a value nobody computed must not decide anything. The message says so where you hit it.
The way out you usually want is the getter from Decoupling BPMN from the data model. boolean isLargeAmount() holds the expression in Java, the model reads nothing but its name, and your data model stays yours. If you really want the value itself in the BPMS, declare it:
vanillabp:
workflow-modules:
my-module:
workflows:
MyProcess:
declared-aggregate-values:
- amount
- shipping.*An entry names a path in the aggregate. A plain name is the attribute itself. shipping.* covers every value below shipping, and shipping.express is one value below it. A single * covers every value of the aggregate. A workflow which already allows the full sync needs just this one line. It is also the bluntest choice. A declaration says that you looked at the value and know what your BPMS does with it.
A business rule task is the normal reason to declare. A decision table decides about numbers and dates, that is its job, and its inputs are variables. Declaring them is the intended way there. It does not break the rule.
Sections of a larger process usually keep their data in one sub-object per section, and until the section has run, that object is null. Then shipping.express resolves to nothing, and a gateway which reads it would decide on a value nobody computed.
VanillaBP stops the workflow there, with a message which names the path and the link which is null. A stopped workflow is better than a wrong path. There are two ways out, and the message names both:
- Make the existence of the section a
booleangetter on the top level, and share the values of the section only while the section runs. An embedded subprocess has its own scope, and a call activity gives the child process its own variables. - Say what is shared while the path does not resolve:
shipping.express=falsein the same list. Declared instead of guessed.
Writing false without saying so would be the worst answer of all, because nobody can tell it from a false somebody computed.
Only your handler reads a @TaskParam. So no expression language is involved, and only the serialization matters. The rules for this direction differ from the ones above on purpose. A BigDecimal is fine as long as an adapter says what its BPMS does with it.
The startup names such a parameter in two cases. In the first, an adapter says that its BPMS returns the type as something else. In the second, the declaration names no type at all. @TaskParam Object gets a BigDecimal from Camunda 7 and a Double from Camunda 8, so it has to be declared and covered by tests. A collection and a Map are the same case one level down, because the serialization decides what their items become. So in this direction a collection is named but a BigDecimal is not. On the way to the BPMS, both need a declaration. In both directions the declaration is the way through.
vanillabp:
workflow-modules:
my-module:
workflows:
MyProcess:
tasks:
myTask:
declared-task-params:
- amountThe key may also be set at the workflow, at the workflow module or at vanillabp itself, and the most specific level wins.
An adapter may answer that it cannot say. That costs a warning, never a failed start. Camunda 8 drops the scale of a decimal inside the broker, and no adapter sees that without a running cluster. And an application has to start even if its BPMS cannot be reached at that moment. What each BPMS really does with a type is answered by the four questions in every adapter wiki, linked above.
The sync points above are the moments when VanillaBP talks to the BPMS anyway. Between them, the BPMS holds the state of the last push, and sometimes that state is one step too old. Maybe a conditional event waits for exactly the change your code just made. Or a gateway would be evaluated before your next task runs. Then tell the BPMS:
@Transactional
public void driverArrived(final String rideId) {
final var ride = rides.findById(rideId).orElseThrow();
ride.setDriverArrived(true);
bpms.aggregateChanged(ride);
}This does not change WHAT is pushed. It is the same shared part of the aggregate, decided by @SyncWithBPMS. The call only says "look again".
The second overload chooses the scope:
bpms.aggregateChanged(ride, taskId); // the scope THAT task runs inThe scope a task runs in is the process, an embedded subprocess, or the one iteration of a multi-instance embedded subprocess the task belongs to. Multi-instance work needs this. Every iteration has a scope of its own, and a write for the whole workflow would lose updates between them. An event subprocess with a conditional start event also listens on this scope. The task id is the one reported to your @TaskId parameter.
This is not the task's own context, by the way. A model may give a task a scope of its own, for example with a boundary event attached to it or as one instance of a multi-instance activity. Values written there would serve only that activity and disappear with it. VanillaBP writes into the scope around it.
A push for a task scope does not also write the global scope. A value written in an inner scope hides the global one there. Writing both would change what the OTHER iterations see, and multi-instance code must never do that by accident. So the global values of the workflow stay as they were. A gateway AFTER the multi-instance evaluates the older state unless you also push globally.
Everything else follows the rules of every operation which reaches a BPMS. Call it within a transaction. The push is planned there as an outbox entry and written once that transaction has committed. This holds for an embedded BPMS as well as for a remote one, because re-evaluating what waits for the change is progress, and progress waits for your commit. VanillaBP finds the BPMS which holds the workflow by asking each one. If the workflow has already ended, the push does nothing and logs a warning. If no BPMS knows the workflow, you get a WorkflowNotFoundException with a helpful message. In both cases your aggregate is saved. Repeating a push does no harm either, because the values are read when the push really happens.
The order of two operations is not guaranteed. Everything scheduled in one transaction is dispatched after the commit. The outbox picks entries by their due time and does not order them against each other. So a push and a task completion of the same workflow may reach the BPMS in the other order. This holds for an embedded BPMS too, because it is dispatched the same way. If the order matters, do not schedule both in one transaction. Give the first operation its own transaction, and open the transaction of the second one only after the first has committed. An entry which is alone in the store is the only one the dispatcher can pick. So a push which has to reach the BPMS before the completion after it gets there first. Your code arranges this order, the store does not promise it, because the dispatch after a commit runs on a thread of its own.
What a BPMS DOES with new values is up to the BPMS. Camunda 7 re-evaluates the conditions of waiting conditional events (this is what makes conditional events usable there at all). Camunda 8 has no conditional events and just holds the values until something reads them. The wiki of each adapter says which.
A workflow aggregate belongs to one workflow, so it looks as if there is only one writer. That is not true. A second token in the model is just the second writer you can see in the drawing. An extension which reports about the workflow writes too. A task delivered to two nodes runs twice. So does your own code, wherever you call it from. The pairs you can run into lists them.
The token is where it usually starts. One token at a time means one writer at a time. That ends with the first construct which puts a second token into the workflow: a non-interrupting boundary event, a parallel or inclusive gateway, a parallel multi-instance activity, a non-interrupting event subprocess, an ad-hoc subprocess.
The classic case is the reminder:
┌──────────────────────┐
│ Wait for approval │─────────► approved
└──────────┬───────────┘
╭────┴────╮ non-interrupting timer, every 2 days
│ timer │───────────────► send reminder, count it
╰─────────╯
While the approval task is open, the reminder branch runs. Both branches change the same aggregate. The reminder branch does it in the transaction VanillaBP opens for its @WorkflowTask method. The approval does it in the transaction your application opens around the API call which answers the task. Each of them loads the aggregate, changes its part and saves it.
A persistence layer writes the whole record. So the transaction which commits second writes back the values it read when it started. What the other branch committed in between is lost. Nothing reports it: no exception, no log line, and the process runs exactly as modelled. That is why people usually find this late, and on the BPMS where the branches overlap longest.
The ad-hoc subprocess is on that list for its own reason. It holds activities without sequence flows between them, and it runs the ones which something outside the model picked. So the drawing does not show how many tokens it produces. The same model runs one activity for one workflow and three for the next. That is why VanillaBP names it even where the list it reads has only one entry today. That list is data, and data changes. Which attribute of a model holds the list, and which BPMS has the element at all, is on the page of the adapter.
Every pair below loses a write in the same way. What differs is who the second writer is, how long the two overlap, and which way out works for them. Read the time window first. A pair which overlaps for milliseconds is a bug you will meet once a year. A pair which overlaps for the length of a remote call is one you will meet this week.
An extension asks VanillaBP to run a method of your workflow service, and that method writes something into the aggregate while it answers. The Business Cockpit does this when it reports a task or a workflow: the provider may write down what it reported. VanillaBP loads the aggregate, runs the method and saves the aggregate afterwards.
You recognise it by a method with an annotation of an extension which sets something on the aggregate. A method which only reads is not a writer here. Also, an extension can say that the aggregate must not be saved at all. It can say this for a single call or for a whole annotation of its own. The Business Cockpit says it when it builds the details of a task somebody opened.
The window starts when VanillaBP loads the aggregate for the handler and ends when that transaction commits. If the extension calls in from a transaction of yours, the handler joins that transaction. Then there is no second writer, only your own code against itself. If the extension calls in after a commit, as a report sent through the outbox does, the window lasts as long as the handler plus the save. That includes whatever the handler asks the outside world.
A version attribute plus a retry in your own code works for this pair. So does an attribute which only the handler ever writes, because then the two sides do not meet. @DynamicUpdate alone does not help if both sides write the same attribute.
Two extensions are installed. Both have a handler for the same event, and both let the aggregate be saved. Each extension plans its own outbox entry. So the two dispatches run on threads of their own and can overlap completely.
You recognise it by two extensions in your dependencies whose annotations are on the same workflow service class. One extension alone cannot tell you about it. So check this pair by hand when you add the second extension.
The window is the overlap of two dispatches, and nothing limits it. The entries are picked independently, and each handler takes as long as it takes.
One entity per extension always works, because then nothing is shared. A version attribute turns the lost write into an exception instead, and the outbox repeats the dispatch which lost. There is no way out inside one extension. So neither @DynamicUpdate nor a retry in your own code helps here.
This is the reminder above. Two tokens, two @WorkflowTask methods, one aggregate, and the branch which commits second wins.
You recognise it in the model, and VanillaBP reports it while the application starts (see what VanillaBP does). The window is the overlap of the two branches. That is usually the longest of all pairs here. A reminder branch next to an approval task overlaps for as long as the approval is open.
One entity per phase fits this pair best. The branches do different things, and a data model which says so is better anyway. @DynamicUpdate fits where the branches write different attributes. Neither fits two branches which write the same attribute. There, a version attribute with a retry is what is left.
A BPMS delivers a task at least once. So the same task can reach two nodes of your application at the same time. Both load the aggregate, both run your @WorkflowTask method, both save.
VanillaBP remembers a processed delivery. It answers a repeated one from that record and does not run your method again. So this pair needs the two deliveries to really overlap: the second one cannot see the record of the first one before it commits. You recognise the situation by running more than one node, which every production deployment does. You see the harmless half of it in the counter vanillabp.task.redeliveries.deduplicated.
The window is the length of your method, once, and it is the same on every BPMS.
A version attribute is the way out. The delivery which lost fails on the conflict, and the BPMS delivers it again. By then the record of the other delivery is there to answer it. @DynamicUpdate does not help with this pair, because both runs write the same attributes. One entity per phase does not help either, because both runs are the same phase.
Two requests change the same aggregate, or a request meets a background job of yours. A typical case is a form which saves a draft by itself while somebody presses submit. VanillaBP is not involved at all, and that is why we name it here. The aggregate is your entity, and everything which is true for your other entities is true for it.
You recognise it by more than one place in your own code which writes the same attribute of the aggregate.
The window is the length of your own transaction.
A version attribute plus a retry is the way out, and only you can write it. One entity per phase does not fit, because both writers are in the same phase.
Two things look like this but are a different problem. Looking for a version attribute there costs time and finds nothing.
A repeated dispatch which runs the same handler twice is one writer, twice. The second run reads what the first one wrote, so nothing is lost and no version attribute notices anything. What it needs is a handler which can safely run again. That is what at-least-once delivery is about. On Spring Boot with JPA the first run is rolled back with the failed dispatch anyway, so the second one starts from the same place as the first one.
A report which arrives late, or two reports which arrive in the other order, is not this either. There the aggregate is intact, and the receiver saw an older state of it. The question is which state a report carries, not which write survived.
Which one fits depends on what the writers do, not on taste. The pairs above name the one which works for them.
- One entity per phase, so the writers do not share a record at all. The reminder branch counts in its own entity, related to the aggregate. The collision cannot happen. The price is a data model which says what the workflow does.
-
@DynamicUpdate(JPA/Hibernate), which writes only the columns a transaction really changed. Writers which touch DIFFERENT attributes then no longer overwrite each other. Two writers which touch the SAME attribute still do. - A version attribute plus a retry of your own.
@Version(JPA as well as Spring Data) turns the silent overwrite into an optimistic locking exception. The transaction which lost has to run again, in your code, where the retry belongs (see below). - A relation you add to instead of an attribute you change. The reminder branch adds a row ("reminder sent at ...") instead of raising a counter, and whoever needs the number counts the rows. Two writers never touch the same value.
- A call to the outside which is sent after the commit, not during the transaction. This one does not prevent the collision. It makes the retry of number 3 safe. A retry is delicate because of the remote call a method may already have made. A call which goes out through a transaction outbox has not happened yet when the transaction rolls back. VanillaBP handles its own work that way, and an extension may plan work of its own in the same store.
"The retry belongs in your code" is true, but it does not help anybody write it. This is what it looks like with Spring's @Retryable:
@Service
public class ApprovalService {
@Retryable(retryFor = OptimisticLockingFailureException.class, maxAttempts = 5)
@Transactional
public void approve(final String aggregateId, final String approver) {
// read INSIDE the retry: the instance of the attempt which lost is stale, and
// saving it again would overwrite the writer which won
final var aggregate = aggregates
.findById(aggregateId)
.orElseThrow();
aggregate.setApprover(approver);
bpms.completeTask(aggregate, "approval");
}
}Three things decide whether this works.
The retried method is the transaction boundary, not something inside it. Say @Retryable sits on a method which runs inside a transaction somebody else opened. It repeats the body, but the failed transaction stays failed. Every attempt after the first one fails on the same rollback-only mark.
The aggregate is read inside the method, never passed in. An instance from before the retry has the version the conflict was about.
Everything the method does outside your database has to be safe to repeat. No annotation fixes that part. If the method calls a remote API, move the call out of the transaction: plan it through an outbox, or make it after the commit. Until then a retry is worse than a failure, because it repeats the call and nobody notices.
VanillaBP does not use this recipe for itself, and it says so where it reports the conflict. A @WorkflowTask method may have called a remote API before the commit failed. A silent retry inside the framework would repeat that call and hide the conflict at the same time. (why)
It tells you about two of the pairs above while the application starts.
The first is the model. An adapter reports the elements of a BPMN process which can put a second token into a running workflow. If the workflow aggregate of that process has no version attribute, you get one WARN per process which names those elements. Compensation is one of them and is named with its shape, because there you need to see which throw event starts which handlers. An aggregate with a version attribute gets no warning, because then the collision is an exception and not a silent overwrite. (why)
VanillaBP also reads the older versions your BPMS still holds, as long as workflows still run on them. Say your newest model dropped a parallel gateway. The gateway still forks every workflow which started before, and those are the workflows which run longest. So a finding from an older model names the version it was read from. It is still one warning per BPMN process. The message is about an aggregate which cannot survive two writers, and the model you can still change is the one which speaks first. A BPMS which cannot read a model it holds says nothing, and a version no workflow runs on is not read at all.
The second is the extension. If an extension has a handler for one of your BPMN processes, and that handler may save the aggregate, you get one WARN per process for that as well. It names the extension and the annotation. The condition is the same: the warning is about an aggregate which cannot notice a second writer. So an aggregate with a version attribute gets no warning. Whether a single call really saves is decided per call, not at startup. That is why the message says that the handler may save, not that it does.
An extension whose handlers never write can say so when it is wired, and then you read nothing about it. So a warning you do read is about an extension which may really write, and you have something to decide.
The other pairs you have to find yourself. Two extensions against each other, a task delivered twice, and your own code against itself leave no trace which a start can read. A check which guesses would be worse than this sentence.
VanillaBP reports the conflict where it owns the transaction: in a @WorkflowTask method, in a workflow the BPMS started by itself, and in the @WorkflowEnded notification. When the commit of one of those fails on a version conflict, one ERROR names the workflow module, the BPMN process, the aggregate and the task. It also says that whatever the run changed is gone.
It never retries. The exception is passed on unchanged, so the BPMS uses its own retry rules: the job is retried as configured, and it ends in an incident once the attempts are used up. The reason not to retry inside the framework is the handler itself. It may have called a remote API before the commit failed, and a quiet retry would repeat that call and hide that anything went wrong. What each BPMS does with such a job is described in its wiki.
A retried delivery runs your handler again. The record of a processed delivery (a repeated delivery does not run your handler again) is written in the same transaction as the change to the aggregate. So a run which failed on a conflict leaves no record behind. That is not a hole in that feature. A handler with side effects has to survive being repeated anyway, and a version conflict is one of the ways this happens.
Where the BPMS owns the transaction, VanillaBP sees nothing of it. An embedded engine (Camunda 7) delivers tasks inside its own transaction. So the conflict fails that transaction, and the engine decides what happens next. It is the same story, seen from the side of the engine.
VanillaBP keeps the number of transactions low where it can. A handler VanillaBP runs for an extension uses the transaction it finds open and opens one only if none is running. So a call from a transaction of yours does not create a second writer in the first place.
The transaction your application opens around an API call is yours, and so is the retry in it. VanillaBP has no say in that code. It does not know that the call belongs to a workflow, and it does not know when repeating it is safe. Read the aggregate again inside the retry. Do not reuse the instance which lost, because the whole point of the version attribute is that this instance is stale.
