soda docs v17.0.1

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

  1. Define the aggregate root (extend AbstractAggregateRoot), suspend events in its business methods
  2. Define the Command (extend BaseCommand<R>)
  3. Implement the CommandHandler
  4. Define the write repository interface (extend WriteRepository<T>) and implement it
  5. 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:

java
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:

  • addPendingEvent is protected — 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 repository save.
  • Context is injected at suspend time — requestId, userName, jti, authorities, callerUid are read from DomainEventContext (a ThreadLocal) and filled into the event.
Note

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:

java
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):

FieldTypeMeaning
requestIdStringRequest tracing ID
callerUidStringCaller UID
userNameStringUser name
authoritiesStringAuthorities string
jtiStringJWT token id
jwtStringJWT token
tenantIdStringTenant ID
userInfoStringUser info
extensionStringExtension field
hopCountIntegerHop counter for async recursion protection
Important

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:

java
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:

  1. Validate the inputs with ParamChecker
  2. Assemble the aggregate root
  3. Call the aggregate root's business methods (change state, suspend events)
  4. Save via the repository, return the result
Warning

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.

Note

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>:

java
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:

java
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

java
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:

java
@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;
    }
}
Warning

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:

java
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:

java
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;
    }
}
Tip

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:

java
@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:

MethodSignatureWhat 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
Warning

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.

Note

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:

java
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.

java
@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.

Important

Whether a transaction exists directly decides when domain events are published:

  • With a transaction — RepositoryEventAspect registers an afterCommit callback; 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 save returns.

That is how “events go out only after the transaction commits” is implemented. Details: Event Bus & Aspect.

The complete chain at a glance

text
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