Write a Command
The write chain is the most central chain in soda. It carries two things the read chain does not: transactions and domain events. This section writes one complete write chain from scratch.
One example runs through the whole page: creating a “production workshop” (Producer) aggregate.
Five steps
- Define the aggregate root (extend
AbstractAggregateRoot), suspend events in its business methods - Define the Command (extend
BaseCommand<R>) - Implement the CommandHandler
- Define the write repository interface (extend
WriteRepository<T>) and implement it - Use assemblers to convert between domain objects ↔ DO / DTO
Step 1: The aggregate root
The aggregate root extends AbstractAggregateRoot. Business methods change state and suspend domain events:
package com.example.demo.domain.producer;
import com.example.demo.domain.enums.ProducerEventTypeEnum;
import com.example.demo.domain.producer.event.ProducerEvent;
import com.example.demo.utils.base.tools.BuKaUid;
import com.hibuka.soda.domain.aggregate.AbstractAggregateRoot;
import lombok.Data;
@Data
public class Producer extends AbstractAggregateRoot {
/**
* uid
*/
private String uid;
/**
* name
*/
private String name;
public boolean create(String name) {
this.uid = BuKaUid.genDomainUid();
this.name = name;
// Add the event - use the producer-created event, not the consumer-created one
addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));
return true;
}
}Three points to remember:
addPendingEventisprotected— outside code cannot stuff events into the aggregate; events can only originate from the aggregate itself.- It suspends, it does not publish — the event goes into a list inside
AbstractAggregateRoot, and the aspect publishes it after the repositorysave. - Context is injected at suspend time —
requestId,userName,jti,authorities,callerUidare read fromDomainEventContext(a ThreadLocal) and filled into the event.
The event-type string comes from ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(). How to write the enum: Domain Events.
AbstractAggregateRoot extends AbstractDomainEvent, so aggregate root objects also carry eventId / eventType / occurredOn fields. This is a historical design; business code never uses it.
Step 2: Define the Command
A Command extends BaseCommand<R>; R is the CommandHandler's return type:
package com.example.demo.application.cqrs.command;
import com.example.demo.adapter.inbound.web.dto.ProducerDTO;
import com.hibuka.soda.cqrs.command.BaseCommand;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class CreateProducerCommand extends BaseCommand<ProducerDTO> {
@Schema(description = "Name", example = "domain_test")
private String name;
}The context fields BaseCommand<R> ships with (all carry @Schema(hidden = true), so they stay out of the API docs):
| Field | Type | Meaning |
|---|---|---|
requestId | String | Request tracing ID |
callerUid | String | Caller UID |
userName | String | User name |
authorities | String | Authorities string |
jti | String | JWT token id |
jwt | String | JWT token |
tenantId | String | Tenant ID |
userInfo | String | User info |
extension | String | Extension field |
hopCount | Integer | Hop counter for async recursion protection |
Put only business parameters in a Command. Context fields are filled in by the framework, and business code must not write business rules inside a Command — that is the aggregate root's job.
Step 3: Implement the CommandHandler
CommandHandler<C extends Command<R>, R> is the CommandHandler interface, and the handle signature is R handle(C command) throws BaseException:
package com.example.demo.application.cqrs.handler.command;
import com.example.demo.application.cqrs.command.CreateProducerCommand;
import com.example.demo.adapter.inbound.web.dto.ProducerDTO;
import com.example.demo.adapter.inbound.web.assembler.ProducerWebAssembler;
import com.example.demo.domain.producer.Producer;
import com.example.demo.infrastructure.assembler.ProducerAssembler;
import com.example.demo.domain.producer.repository.ProducerWriteRepository;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.ParamChecker;
import com.hibuka.soda.cqrs.command.CommandHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class CreateProducerCommandHandler implements CommandHandler<CreateProducerCommand, ProducerDTO> {
private final ProducerWriteRepository producerWriteRepository;
@Override
public ProducerDTO handle(CreateProducerCommand command) throws BaseException {
commandValidate(command);
Producer domain = ProducerAssembler.toDomain(command);
domain.create(command.getName());
producerWriteRepository.save(domain);
return ProducerWebAssembler.toDTO(domain);
}
private void commandValidate(CreateProducerCommand command) throws BaseException {
ParamChecker.notNull(command, "command");
ParamChecker.notNull(command.getName(), "name");
}
}A CommandHandler's four steps are fixed:
- Validate the inputs with
ParamChecker - Assemble the aggregate root
- Call the aggregate root's business methods (change state, suspend events)
- Save via the repository, return the result
What you inject in a CommandHandler must be the interface ProducerWriteRepository, never the implementation class ProducerWriteRepositoryImpl. Otherwise the application layer depends directly on the infrastructure layer.
Handlers need no explicit registration. At startup the bus scans List<CommandHandler<?, ?>> and resolves each CommandHandler's command type via GenericTypeResolver.resolveTypeArguments, building an index. A command type maps to exactly one CommandHandler — a later registration overwrites the earlier one. Full comparison of the three Handler kinds: Handler.
Step 4: The write repository
Interface
The write repository interface extends WriteRepository<T extends AbstractAggregateRoot>:
package com.example.demo.domain.producer.repository;
import com.example.demo.domain.producer.Producer;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.domain.repository.WriteRepository;
public interface ProducerWriteRepository extends WriteRepository<Producer> {
/**
* @param producer
* @description: save the Producer
* @return: boolean
*/
boolean save(Producer producer) throws BaseException;
}WriteRepository<T> fixes four methods:
public interface WriteRepository<T extends AbstractAggregateRoot> {
boolean save(T aggregate) throws BaseException;
boolean update(T aggregate) throws BaseException;
boolean delete(T aggregate) throws BaseException;
boolean operate(T aggregate, int operationType) throws BaseException;
}operate dispatches by operation type; operationType is an int, usually from your own operation-type enum.
Implementation
package com.example.demo.infrastructure.repositoryImpl.producer;
import com.example.demo.domain.producer.Producer;
import com.example.demo.domain.producer.repository.ProducerWriteRepository;
import com.example.demo.infrastructure.acl.mysql.entity.ScodaProducer;
import com.example.demo.infrastructure.acl.mysql.mapper.ScodaProducerMapper;
import com.example.demo.infrastructure.assembler.ProducerAssembler;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hibuka.soda.foundation.error.BaseErrorCode;
import com.hibuka.soda.foundation.error.BaseException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import com.example.demo.domain.enums.OperationTypeEnum;
@Component
@RequiredArgsConstructor
public class ProducerWriteRepositoryImpl implements ProducerWriteRepository {
private final ScodaProducerMapper scodaProducerMapper;
public boolean save(Producer producer) throws BaseException {
ScodaProducer scodaProducer = ProducerAssembler.toDO(producer);
try {
return scodaProducerMapper.insert(scodaProducer) > 0;
} catch (Exception e) {
throw new BaseException(BaseErrorCode.DATABASE_ERROR.getCode(), e.getMessage());
}
}
public boolean update(Producer producer) throws BaseException {
try {
LambdaUpdateWrapper<ScodaProducer> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(ScodaProducer::getUid, producer.getUid())
.set(ScodaProducer::getName, producer.getName())
.set(ScodaProducer::getGmtModified, LocalDateTime.now());
return scodaProducerMapper.update(null, wrapper) > 0;
} catch (Exception e) {
throw new BaseException(BaseErrorCode.DATABASE_ERROR.getCode(), e.getMessage());
}
}
public boolean delete(Producer producer) throws BaseException {
try {
LambdaQueryWrapper<ScodaProducer> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ScodaProducer::getUid, producer.getUid());
return scodaProducerMapper.delete(wrapper) > 0;
} catch (Exception e) {
throw new BaseException(BaseErrorCode.DATABASE_ERROR.getCode(), e.getMessage());
}
}
public boolean operate(Producer producer, int operationType) throws BaseException {
if (operationType == OperationTypeEnum.CREATE.getCode()) {
return save(producer);
} else if (operationType == OperationTypeEnum.UPDATE.getCode()) {
return update(producer);
} else if (operationType == OperationTypeEnum.DELETE.getCode()) {
return delete(producer);
}
throw new BaseException(BaseErrorCode.SYSTEM_ERROR.getCode(), "Unsupported operationType: " + operationType);
}
}None of the four method names can change — soda's RepositoryEventAspect intercepts them by these four names through a pointcut. Rename them, and domain events stop going out.
The operation-type enum:
@Getter
public enum OperationTypeEnum implements CodeEnum {
CREATE(1, "create"),
UPDATE(2, "update"),
DELETE(3, "delete"),
CUSTOM(99, "custom");
private final Integer code;
private final String description;
OperationTypeEnum(Integer code, String description) {
this.code = code;
this.description = description;
}
@Override
public String getDesc() {
return this.description;
}
}Repository type names must match *Repository. The aspect's match expression is execution(* *..*Repository+.save(..)), where + means “this type and its subtypes”. So an interface named ProducerWriteRepository is enough — the implementation can be called ProducerWriteRepositoryImpl or ProducerRepoImpl and both get intercepted. But if neither the interface nor the implementation has Repository in its name (say ProducerStore + ProducerStoreImpl), the aspect never sees them: the save succeeds, the event is silently lost, with no error at all.
Also, any class ending in Repository with a save method is visible to this aspect. The aspect checks whether the first parameter is a subclass of AbstractAggregateRoot and skips it if not — so false matches are limited.
Step 5: Assemblers
The data-layer assembler handles DO ↔ domain objects, plus Command → domain objects:
package com.example.demo.infrastructure.assembler;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.demo.application.cqrs.command.CreateProducerCommand;
import com.example.demo.domain.producer.Producer;
import com.example.demo.infrastructure.acl.mysql.entity.ScodaProducer;
import java.time.LocalDateTime;
import java.util.stream.Collectors;
public class ProducerAssembler {
public static Producer toDomain(ScodaProducer doRoot) {
if (doRoot == null) {
return null;
}
Producer producer = new Producer();
producer.setUid(doRoot.getUid());
producer.setName(doRoot.getName());
return producer;
}
public static Page<Producer> toDomain(Page<ScodaProducer> doRoot) {
if (doRoot == null) {
return null;
}
Page<Producer> to = new Page<>();
to.setTotal(doRoot.getTotal());
to.setRecords(doRoot.getRecords().stream().map(i -> toDomain(i)).collect(Collectors.toList()));
to.setCurrent(doRoot.getCurrent());
to.setSize(doRoot.getSize());
to.setPages(doRoot.getPages());
return to;
}
public static Producer toDomain(CreateProducerCommand doRoot) {
if (doRoot == null) {
return null;
}
Producer producer = new Producer();
producer.setName(doRoot.getName());
return producer;
}
public static ScodaProducer toDO(Producer producer) {
if (producer == null) {
return null;
}
ScodaProducer to = new ScodaProducer();
LocalDateTime currentTime = LocalDateTime.now();
to.setUid(producer.getUid());
to.setName(producer.getName());
to.setGmtCreate(currentTime);
to.setGmtModified(currentTime);
return to;
}
}Note that toDomain(CreateProducerCommand) copies only name, not uid — uid is generated by the aggregate root in create(); business code should not mint its own.
The web-layer assembler handles domain objects → DTO:
package com.example.demo.adapter.inbound.web.assembler;
public class ProducerWebAssembler {
public static ProducerDTO toDTO(Producer producer) {
if (producer == null) {
return null;
}
ProducerDTO to = new ProducerDTO();
to.setUid(producer.getUid());
to.setName(producer.getName());
return to;
}
}Keep the two assemblers apart: the data-layer assembler (infrastructure.assembler) and the web-layer assembler (adapter.inbound.web.assembler). DTOs belong to the adapter layer; they have no business showing up in the infrastructure layer.
Sending commands: BusFacade
BusFacade is where business code meets the bus:
@Service
@RequiredArgsConstructor
public class SampleAppServiceImpl implements SampleAppService {
private final BusFacade busFacade;
@Override
public ProducerDTO creatProducer(CreateProducerCommand command) throws BaseException {
return busFacade.sendTransactCommand(command);
}
@Override
public CompletableFuture<ProducerDTO> creatProducerSyn(CreateProducerCommand command) throws BaseException {
return busFacade.sendAsyncCommand(command);
}
}The sending methods:
| Method | Signature | What it does |
|---|---|---|
sendCommand | <C extends Command<R>, R> R sendCommand(C) | Sends synchronously |
sendTransactCommand | <C extends Command<R>, R> R sendTransactCommand(C) | Sends inside a transaction — the default choice for the write chain |
sendAsyncCommand | <C extends Command<R>, R> CompletableFuture<R> sendAsyncCommand(C) | Sends asynchronously, returns a CompletableFuture |
sendMqCommand | <C extends Command<R>, R> R sendMqCommand(C) | Not implemented — calling it throws UnsupportedOperationException |
sendQuery | <Q extends BaseQuery<R>, R> R sendQuery(Q) | Sends a query |
sendMqCommand is currently an empty stub — calling it throws UnsupportedOperationException("sendMqCommand feature is not yet implemented, do not call!") right away. Do not use it.
sendCommand and sendTransactCommand are currently implemented identically — both synchronously call commandBus.send(command). The difference is semantics: sendTransactCommand states explicitly that “this command runs inside a transaction”; sendCommand makes no such promise. Use sendTransactCommand on the write chain.
Context in async commands
sendAsyncCommand captures the current CommandContext and restores it on the async thread:
final CommandContext context = CommandContextHolder.getContext();
return CompletableFuture.supplyAsync(() -> {
if (context != null) {
CommandContextHolder.setContext(context);
}
try {
return commandBus.send(command);
} catch (BaseException e) {
throw new RuntimeException(e);
} finally {
if (context != null) {
CommandContextHolder.clearContext();
}
}
}, cqrsAsyncExecutor);So context like requestId and userName doesn't get lost on async chains.
Where the transaction is
The transaction boundary is decided by the @Transactional on your app service or Controller — soda does not open transactions for you.
@Service
@RequiredArgsConstructor
public class ProducerAppServiceImpl implements ProducerAppService {
private final BusFacade busFacade;
@Transactional(rollbackFor = Exception.class)
@Override
public ProducerDTO creatProducer(CreateProducerCommand command) throws BaseException {
return busFacade.sendTransactCommand(command);
}
}Always write rollbackFor = Exception.class — by default only RuntimeException rolls back, and checked exceptions commit the transaction.
Whether a transaction exists directly decides when domain events are published:
- With a transaction —
RepositoryEventAspectregisters anafterCommitcallback; events are published only after the transaction commits. If the transaction rolls back, the events never go out. - Without a transaction — events are published the moment
savereturns.
That is how “events go out only after the transaction commits” is implemented. Details: Event Bus & Aspect.
The complete chain at a glance
HTTP POST /api/creatProducer
Header: Soda-Request-Id: abc-123
Body: {"name": "domain_test"}
│
├─ ControllerAroundHandler
│ ├─ requestId = "abc-123", username read from request headers
│ ├─ command.setRequestId(...) / command.setUserName(...)
│ └─ MDC.put("requestId", ...)
│
├─ Controller: binds CreateProducerCommand, calls the app service
│
├─ AppService (@Transactional): busFacade.sendTransactCommand(command)
│
├─ CommandBus.send(command)
│ ├─ finds CreateProducerCommandHandler by Command type
│ └─ handler.handle(command)
│
├─ CqrsAroundHandler (intercepts CommandBus.send): logs timing and in/out params
│
├─ CreateProducerCommandHandler.handle(command)
│ ├─ ParamChecker.notNull(command.getName(), "name")
│ ├─ ProducerAssembler.toDomain(command) → Producer
│ ├─ domain.create(name)
│ │ ├─ uid = BuKaUid.genDomainUid()
│ │ └─ addPendingEvent(new ProducerEvent("create_producer", this)) ← suspend + inject context
│ ├─ producerWriteRepository.save(domain)
│ │ ├─ aspect intercepts: runs the actual insert
│ │ ├─ takes domain.getDomainEvents() and clears it
│ │ └─ registers afterCommit callback (publish after commit)
│ └─ ProducerWebAssembler.toDTO(domain) → ProducerDTO
│
│ ↓ transaction commits
│
├─ afterCommit → eventBus.publish(event)
│ └─ ProducerEventHandler.handle(event)
│
└─ Result.success(dto)FAQ
Q: What happens when a command has no Handler?
SimpleCommandBus.send throws BaseException with error code CLASS_NOT_FOUND_ERROR (10005) and the message No handler registered for command: com.example.demo.application.cqrs.command.CreateProducerCommand. Check two things: whether the CommandHandler carries @Component, and whether the command class's generics are written correctly.
Q: Can one CommandHandler send another command?
Yes, but watch for recursion. BusFacade has synchronous recursion-depth protection: past 10 levels it throws IllegalStateException("Command recursion too deep! Trace: ..."). Details: Recursion Protection.
Q: How do updates and deletes work?
Same as create, just different aggregate root business methods (update / delete) and repository calls to update / delete. RepositoryEventAspect treats all four methods alike — events are published after the transaction commits.
Q: Is operate mandatory?
Yes. WriteRepository declares it, and an implementation class that skips it won't compile. If you don't want it, throw a BaseException to say it is unsupported.
Q: Should I handle a repository method returning false?
Best to. A save returning false usually means zero affected rows, and the event still gets published (the aspect takes the events unconditionally after save returns). If you want “no successful save, no event”, check the return value in the CommandHandler and throw to roll back.
Next steps
- The read chain: Write a Query
- How events go out: Event Bus & Aspect
- Hooking up the web layer: Web Layer