soda docs v17.0.1

Quickstart

You write no code. First generate a runnable project from the generator — H2 in-memory database, schema script, and complete DDD sample code all included. Your only job is mvn spring-boot:run, then watch it work in the browser.

Once it runs, come back to Core Concepts and Write a Command — it will go much faster.

Step 1: Generate the project

Open https://start.soda.hibuka.com, pick a template, fill in three fields, download the zip.

FieldWhat it isExample
Project nameMaven's artifactIdmy-soda-project
Package nameThe Java package all code lives undercom.example.soda
Spring Boot version4.0 or 3.2 — match your stack4.0

Pick the SODA Simple template (monolith). Choose scoda-cloud if you need a multi-module layout.

The generated project already contains:

  • A complete DDD layering layout (adapter / application / domain / infrastructure)
  • Full CRUD for a “Producer” aggregate: commands, queries, events, repositories
  • H2 in-memory database config + the schema-h2.sql schema script
  • MyBatis-Plus, Knife4j (Swagger), and unified exception handling

Step 2: Just run it

bash
unzip my-soda-project.zip
cd my-soda-project
mvn spring-boot:run

No MySQL to install, no Redis to install. When startup completes you'll see:

text
Tomcat started on port 8080

The generated project defaults to “runs out of the box”:

SettingValueWhy
Port8080application.yaml
Profiledevactive by default
Data sourcejdbc:h2:mem:testdbin-memory, in-process
Schemaclasspath:schema-h2.sql, run automaticallyrebuilt on every start
Event busunset → SimpleEventBusin-process dispatch, no Redis involved
Tip

H2 is an in-memory database: stop the process and the data is gone. For “getting it running” that's a feature (every start is a clean environment); for “demonstrating persistence” it's a limitation. Switching to MySQL only touches spring.datasource in application-dev.yaml — not a line of Java changes.

Step 3: Call the four endpoints

The sample ships with a set of endpoints covering all four chains, all POST:

EndpointWhich chainRequest body
/api/creatProducerCommand (sync){"name": "Acme Electronics"}
/api/creatProducerSyncCommand (async){"name": "Acme Electronics"}
/api/getProducerQuery (single){"uid": "the uid from the step above"}
/api/listProducersQuery (paged){"pageNumber": 1, "pageSize": 10}

Create one:

bash
curl -X POST http://localhost:8080/api/creatProducer \
  -H 'Content-Type: application/json' \
  -d '{"name": "Acme Electronics"}'

Response:

json
{
  "code": 200,
  "message": "",
  "data": {
    "uid": "a1b2c3d4-...",
    "name": "Acme Electronics"
  },
  "success": true
}
Note

The creat in the endpoint names is missing an e — a historical misspelling in the generator template, frozen into the sample code. Spell your own business endpoints create; this is sample-template flavor, not a framework constraint.

Query it back to confirm it really persisted:

bash
curl -X POST http://localhost:8080/api/listProducers \
  -H 'Content-Type: application/json' \
  -d '{"pageNumber": 1, "pageSize": 10}'

You should see the record you just created in data. This step is the key one: the command chain worked, the row is in the database, and the query chain reads it back — a complete CQRS round trip.

Step 4: Look inside the database

H2 ships a web console at http://localhost:8080/h2-console:

FieldValue
JDBC URLjdbc:h2:mem:testdb
User Namesa
Passwordleave empty

Click Connect and you'll see two tables on the left: scoda_producer and scoda_consumer. Run:

sql
SELECT uid, name, gmt_create FROM scoda_producer;
Tip

The scoda_ table prefix is a template convention, not a framework rule. schema-h2.sql and the ScodaProducer entity mapping are consistent — if you rename the tables, change both.

Step 5: Read the code — how one command completes the journey

This is the real point of this page. The sample code is not decoration; it plays through every soda chain once. Read the table below top to bottom and you'll understand the full lifecycle of a command.

OrderFileRole in the chain
1adapter/inbound/web/SampleController.javaHTTP entry point; turns the request body straight into a Command
2application/sample/service/impl/SampleAppServiceImpl.javaApplication service; hands it off via BusFacade
3bus/facade/BusFacade (framework)Puts the command on the bus and finds the CommandHandler
4application/cqrs/handler/command/CreateProducerCommandHandler.javaOrchestration: validate → assemble the aggregate → save via repository
5domain/producer/Producer.javaAggregate root; changes state + suspends the domain event
6domain/producer/repository/ProducerWriteRepository.javaWrite repository interface (the domain layer depends only on interfaces)
7infrastructure/repositoryImpl/producer/ProducerWriteRepositoryImpl.javaImplementation, backed by MyBatis-Plus
8domain/producer/event/ProducerEvent.javaDomain event, produced by the aggregate root
9application/cqrs/handler/evnet/ProducerEventHandler.javaEvent consumer, invoked automatically

Three places worth pausing on

1. The event is raised by the aggregate root, not sent by a service

This line in Producer.create():

java
addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));

addPendingEvent is protected — only the aggregate root itself can call it. A service layer trying to fire an event behind the aggregate's back won't even compile. This is the hard guarantee that “events must originate from a domain state change”.

At this point the event is only suspended, not yet published. When does it go out? After the repository save(). The RepositoryEventAspect aspect takes the suspended events and dispatches them after a successful save (after commit, if there is a transaction).

2. The domain layer doesn't know MyBatis-Plus exists

domain/producer/repository/ProducerWriteRepository.java is a pure interface extending WriteRepository<Producer>:

java
public interface ProducerWriteRepository extends WriteRepository<Producer> {
}

WriteRepository fixes four methods: save / update / delete / operate. The CommandHandler injects this interface, not ProducerWriteRepositoryImpl.

The implementation lives in the infrastructure layer, persisting with MyBatis-Plus. Swap in JPA, Redis, or an in-memory Map — the domain layer doesn't change.

Warning

Repository type names must contain Repository. The event-publishing pointcut is *..*Repository+, matching “types whose name ends with Repository, plus their subtypes”. With the interface named ProducerWriteRepository, the implementation can be named anything and still gets intercepted; but rename the interface to ProducerStore and the implementation to ProducerStoreImpl and the aspect no longer sees them — events silently stop being published, no error, just gone.

3. Handlers need no registration

ProducerEventHandler carries @Component, and that's it — no subscribe, no registry, no XML. At startup the framework scans all EventHandlers, resolves the event type each one handles from its generics, and builds an index. The moment ProducerEvent is published, it is invoked automatically.

What the layered layout looks like

text
src/main/java/com/example/soda/
├── adapter/inbound/web/         # entry: controllers, DTOs, assemblers, exception handling
├── application/                 # application layer: Command / Query / Handler / app services
│   ├── cqrs/command/
│   ├── cqrs/query/
│   ├── cqrs/handler/command/
│   ├── cqrs/handler/query/
│   └── cqrs/handler/evnet/      # event consumers (this is the template's spelling)
├── domain/                      # domain layer: aggregates, events, repository interfaces
│   ├── producer/
│   ├── consumer/
│   └── enums/
└── infrastructure/              # infrastructure: DOs, mappers, repository impls, external clients
    ├── acl/mysql/
    ├── acl/redis/
    ├── repositoryImpl/
    └── assembler/

There is only one dependency direction: adapter → application → domain, with infrastructure implementing the domain's interfaces from the other side. No Spring / MyBatis / Redis import ever appears in the domain package.

Step 6: Change one line and watch it move

Add a log line inside ProducerEventHandler.handle():

java
@Override
public void handle(ProducerEvent event) throws BaseException {
    log.info("producer event received: {}, producer: {}", event.getEventType(), event.getProducer().getName());
    eventValidate(event);
}

Restart and call /api/creatProducer again. The console prints this line — proof that the event traveled from the aggregate root, through the repository, through the bus, to the consumer.

Step 7: Switch to production shape

The sample runs on H2 + an in-process bus. Going to production means swapping two things:

SwapWhere
H2 → MySQLspring.datasource in application-dev.yaml, plus a schema.sql to create tables
In-process events → Redis Streamssoda.event.bus-type: redis + Redis connection settings
yaml
soda:
  event:
    bus-type: redis
    redis:
      host: ${SODA_REDIS_HOST}
      password: ${SODA_REDIS_PASSWORD}
      stream:
        idempotency:
          enabled: true
Important

Always write bus-type explicitly. Omitting it works (falls back to the in-process bus) but leaves intent unclear; writing spring creates no EventBus at all and the app dies at startup with NoSuchBeanDefinitionException. Details: Configuration.

What you now have running

Up to this point a complete soda application is in place:

  • Aggregate roots guard state changes and raise the events
  • Commands run the write chain (sync + async), queries run the read chain (single + paged)
  • Events are published automatically on save, and consumers receive them automatically
  • Unified response envelopes Result / PageResult
  • The domain layer has zero dependency on infrastructure

Next steps: